Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion beeminder.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"sort"
"strings"
"time"
)

// Goal represents a Beeminder goal with relevant fields
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Comment thread
narthur marked this conversation as resolved.

// 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 {
Expand Down
27 changes: 27 additions & 0 deletions beeminder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion next.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand Down
35 changes: 32 additions & 3 deletions review.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package main
import (
"context"
"fmt"
"math"
"net/url"
"os"
"os/exec"
"runtime"
"strconv"
"time"

tea "github.com/charmbracelet/bubbletea"
Expand Down Expand Up @@ -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)
Comment thread
narthur marked this conversation as resolved.
Comment thread
narthur marked this conversation as resolved.
}

// formatRecentDatapoints formats up to 5 of the most recent datapoints for
Expand Down Expand Up @@ -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))
}
Comment thread
narthur marked this conversation as resolved.
Comment thread
narthur marked this conversation as resolved.
details += fmt.Sprintf("Rate: %s\n", rateStr)
}

Expand Down
95 changes: 95 additions & 0 deletions review_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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{
{
Expand Down
Loading