diff --git a/README.md b/README.md index 3b94283..1446acd 100644 --- a/README.md +++ b/README.md @@ -27,20 +27,25 @@ So I decided to recreate it. - `GET /progress/{percentage}` - `HEAD /progress/{percentage}` -`percentage` must be an integer and is clamped to `0..100`. +`percentage` can be an integer or float and is clamped to `0..100`. ### Query params - `dangerColor` - `warningColor` - `successColor` +- `barColor` (overrides the bar fill color) +- `label` (custom text inside the bar, max 64 chars) +- `min` +- `max` All color values must be 6-character hex values without `#` (example: `ff9900`). +`min` and `max` must be provided together when used. ### Response behavior - `200 OK`: valid request, returns SVG. -- `400 Bad Request`: invalid percentage or color format. +- `400 Bad Request`: invalid numeric input, range config, label length, or color format. - `405 Method Not Allowed`: any method different from `GET` or `HEAD`. - `500 Internal Server Error`: template parse/render failure. @@ -60,6 +65,10 @@ Headers for successful responses: ![](https://geps.dev/progress/75) +Float percentage: + +![](https://geps.dev/progress/76.5) + ### Custom colors You can customize colors through query params: @@ -80,6 +89,14 @@ Rendered examples: ![](https://geps.dev/progress/75?dangerColor=800000&warningColor=ff9900&successColor=006600) +### Data bar mode (from arbitrary numeric range) + +```md +![](https://geps.dev/progress/186?label=186&min=0&max=241&barColor=4472C4) +``` + +![](https://geps.dev/progress/186?label=186&min=0&max=241&barColor=4472C4) + ## 🛠️ Local Development ### Prerequisites diff --git a/progress.go b/progress.go index c1fc248..859f5a2 100644 --- a/progress.go +++ b/progress.go @@ -3,6 +3,7 @@ package progress import ( "bytes" "log" + "math" "net/http" "os" "path" @@ -11,6 +12,7 @@ import ( "strings" "text/template" "time" + "unicode/utf8" "github.com/GoogleCloudPlatform/functions-framework-go/functions" ) @@ -18,17 +20,18 @@ import ( // Data ... is the collection of inputs we need to fill our template type Data struct { BackgroundColor string - Percentage int + Label string Progress int PickedColor string } const ( gcloudFuncSourceDir = "serverless_function_source_code" - minPercentage = 0 - maxPercentage = 100 - totalBarWidth = 90 + minPercentage = 0.0 + maxPercentage = 100.0 + totalBarWidth = 90.0 cacheControlValue = "public, max-age=300" + maxLabelRunes = 64 ) var ( @@ -49,7 +52,7 @@ func fixDir() { } } -func clampPercentage(percentage int) int { +func clampPercentage(percentage float64) float64 { if percentage < minPercentage { return minPercentage } @@ -61,8 +64,8 @@ func clampPercentage(percentage int) int { return percentage } -func percentageToWidth(percentage int) int { - return (totalBarWidth * percentage) / maxPercentage +func percentageToWidth(percentage float64) int { + return int((totalBarWidth * percentage) / maxPercentage) } func parseOptionalColor(raw string) (string, bool) { @@ -77,7 +80,7 @@ func parseOptionalColor(raw string) (string, bool) { return "#" + strings.ToLower(raw), true } -func pickColor(percentage int, successColor string, warningColor string, dangerColor string) string { +func pickColor(percentage float64, successColor string, warningColor string, dangerColor string) string { pickedColor := green if successColor != "" { pickedColor = successColor @@ -100,6 +103,21 @@ func pickColor(percentage int, successColor string, warningColor string, dangerC return pickedColor } +func formatNumber(value float64) string { + formatted := strconv.FormatFloat(value, 'f', 2, 64) + formatted = strings.TrimRight(formatted, "0") + formatted = strings.TrimRight(formatted, ".") + if formatted == "-0" || formatted == "" { + return "0" + } + + return formatted +} + +func formatPercentLabel(value float64) string { + return formatNumber(value) + "%" +} + func init() { fixDir() progressTemplate, progressTemplateErr = template.ParseFiles("progress.html") @@ -135,16 +153,14 @@ func Progress(w http.ResponseWriter, r *http.Request) { } id := path.Base(strings.TrimSuffix(r.URL.Path, "/")) - percentage, err := strconv.Atoi(id) - if err != nil { + value, err := strconv.ParseFloat(id, 64) + if err != nil || math.IsNaN(value) || math.IsInf(value, 0) { statusCode = http.StatusBadRequest - http.Error(w, "percentage must be an integer", statusCode) + http.Error(w, "percentage must be a number", statusCode) return } - percentage = clampPercentage(percentage) - - // Read and validate success, warning, and danger colors if provided. + // Read and validate colors if provided. successColor, ok := parseOptionalColor(r.URL.Query().Get("successColor")) if !ok { statusCode = http.StatusBadRequest @@ -166,11 +182,69 @@ func Progress(w http.ResponseWriter, r *http.Request) { return } + barColor, ok := parseOptionalColor(r.URL.Query().Get("barColor")) + if !ok { + statusCode = http.StatusBadRequest + http.Error(w, "barColor must be a 6-character hex value", statusCode) + return + } + + customLabel := r.URL.Query().Get("label") + if utf8.RuneCountInString(customLabel) > maxLabelRunes { + statusCode = http.StatusBadRequest + http.Error(w, "label is too long (max 64 characters)", statusCode) + return + } + + minRaw := r.URL.Query().Get("min") + maxRaw := r.URL.Query().Get("max") + hasMin := minRaw != "" + hasMax := maxRaw != "" + + if hasMin != hasMax { + statusCode = http.StatusBadRequest + http.Error(w, "min and max must be provided together", statusCode) + return + } + + percentage := clampPercentage(value) + if hasMin { + minValue, minErr := strconv.ParseFloat(minRaw, 64) + maxValue, maxErr := strconv.ParseFloat(maxRaw, 64) + if minErr != nil || maxErr != nil || math.IsNaN(minValue) || math.IsNaN(maxValue) || math.IsInf(minValue, 0) || math.IsInf(maxValue, 0) { + statusCode = http.StatusBadRequest + http.Error(w, "min and max must be numeric values", statusCode) + return + } + + if maxValue <= minValue { + statusCode = http.StatusBadRequest + http.Error(w, "max must be greater than min", statusCode) + return + } + + normalized := ((value - minValue) / (maxValue - minValue)) * maxPercentage + percentage = clampPercentage(normalized) + } + + pickedColor := pickColor(percentage, successColor, warningColor, dangerColor) + if barColor != "" { + pickedColor = barColor + } + + label := formatPercentLabel(percentage) + if hasMin { + label = formatNumber(value) + } + if customLabel != "" { + label = customLabel + } + data := Data{ BackgroundColor: grey, - Percentage: percentage, + Label: label, Progress: percentageToWidth(percentage), - PickedColor: pickColor(percentage, successColor, warningColor, dangerColor), + PickedColor: pickedColor, } buf := new(bytes.Buffer) diff --git a/progress.html b/progress.html index c16ad51..520bcd3 100644 --- a/progress.html +++ b/progress.html @@ -8,7 +8,7 @@ - {{.Percentage}}% + {{.Label}} diff --git a/progress_test.go b/progress_test.go index dc56353..6a3cf19 100644 --- a/progress_test.go +++ b/progress_test.go @@ -77,6 +77,51 @@ func TestProgressReturnsSVGForValidInput(t *testing.T) { } } +func TestProgressSupportsFloatPercentages(t *testing.T) { + rr := executeProgressRequest("/progress/76.5") + if rr.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rr.Code) + } + + body := rr.Body.String() + if !strings.Contains(body, "76.5%") { + t.Fatalf("expected decimal percentage in body, got %q", body) + } + if !strings.Contains(body, `width="68" height="20" fill="#5cb85c"`) { + t.Fatalf("expected decimal percentage width/color in SVG body, got %q", body) + } +} + +func TestProgressSupportsDataBarMode(t *testing.T) { + rr := executeProgressRequest("/progress/186?label=186&min=0&max=241&barColor=4472C4") + if rr.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rr.Code) + } + + body := rr.Body.String() + if !strings.Contains(body, "186") { + t.Fatalf("expected custom label in body, got %q", body) + } + if !strings.Contains(body, `width="69" height="20" fill="#4472c4"`) { + t.Fatalf("expected scaled width/custom color in body, got %q", body) + } +} + +func TestProgressDataBarDefaultsLabelToRawValue(t *testing.T) { + rr := executeProgressRequest("/progress/50?min=0&max=200") + if rr.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rr.Code) + } + + body := rr.Body.String() + if !strings.Contains(body, "50\n") || strings.Contains(body, "50%") { + t.Fatalf("expected raw value label in body, got %q", body) + } + if !strings.Contains(body, `width="22" height="20" fill="#d9534f"`) { + t.Fatalf("expected scaled width and threshold color in body, got %q", body) + } +} + func TestProgressSupportsHEAD(t *testing.T) { rr := executeProgressRequestWithMethod(http.MethodHead, "/progress/76") @@ -116,7 +161,7 @@ func TestProgressReturnsBadRequestForInvalidPercentage(t *testing.T) { t.Fatalf("expected status 400, got %d", rr.Code) } - if !strings.Contains(rr.Body.String(), "percentage must be an integer") { + if !strings.Contains(rr.Body.String(), "percentage must be a number") { t.Fatalf("expected invalid percentage message, got %q", rr.Body.String()) } } @@ -133,6 +178,59 @@ func TestProgressReturnsBadRequestForInvalidColor(t *testing.T) { } } +func TestProgressReturnsBadRequestForInvalidBarColor(t *testing.T) { + rr := executeProgressRequest("/progress/50?barColor=nothex") + + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", rr.Code) + } + + if !strings.Contains(rr.Body.String(), "barColor must be a 6-character hex value") { + t.Fatalf("expected invalid bar color message, got %q", rr.Body.String()) + } +} + +func TestProgressRequiresMinAndMaxTogether(t *testing.T) { + rr := executeProgressRequest("/progress/50?min=0") + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "min and max must be provided together") { + t.Fatalf("expected min/max pair message, got %q", rr.Body.String()) + } +} + +func TestProgressRejectsInvalidMinMaxValues(t *testing.T) { + rr := executeProgressRequest("/progress/50?min=abc&max=100") + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "min and max must be numeric values") { + t.Fatalf("expected min/max numeric message, got %q", rr.Body.String()) + } +} + +func TestProgressRejectsInvalidRangeOrder(t *testing.T) { + rr := executeProgressRequest("/progress/50?min=10&max=10") + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "max must be greater than min") { + t.Fatalf("expected max greater than min message, got %q", rr.Body.String()) + } +} + +func TestProgressRejectsTooLongLabel(t *testing.T) { + tooLongLabel := strings.Repeat("x", 65) + rr := executeProgressRequest("/progress/50?label=" + tooLongLabel) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "label is too long") { + t.Fatalf("expected label length message, got %q", rr.Body.String()) + } +} + func TestProgressClampsPercentageToRange(t *testing.T) { high := executeProgressRequest("/progress/150") if high.Code != http.StatusOK {