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:

+Float percentage:
+
+
+
### Custom colors
You can customize colors through query params:
@@ -80,6 +89,14 @@ Rendered examples:

+### Data bar mode (from arbitrary numeric range)
+
+```md
+
+```
+
+
+
## 🛠️ 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 {