// Package render draws ZenMux subscription usage to the terminal. package render import ( "fmt" "io" "strconv" "strings" "time" "github.com/fatih/color" "github.com/kotoyuuko/zenmux-usage-cli/internal/api" ) const ( barWidth = 30 barFilled = "█" barEmpty = "░" ) // AccountResult bundles a single account's fetch outcome for RenderAll. type AccountResult struct { Name string Response *api.Response Err error } // ProgressBar returns a width-char string of filled and empty glyphs // representing percent in [0, 1]. Out-of-range inputs are clamped. func ProgressBar(percent float64, width int) string { if percent < 0 { percent = 0 } if percent > 1 { percent = 1 } filled := int(percent*float64(width) + 0.5) if filled > width { filled = width } return strings.Repeat(barFilled, filled) + strings.Repeat(barEmpty, width-filled) } // BandColor returns a fatih/color attribute based on usage thresholds: // green < 60%, yellow 60–85%, red > 85%. func BandColor(percent float64) color.Attribute { switch { case percent > 0.85: return color.FgRed case percent >= 0.60: return color.FgYellow default: return color.FgGreen } } // RenderAll writes one block per account, separated by a blank line. // useColor controls ANSI emission; callers handle TTY/NO_COLOR detection. func RenderAll(w io.Writer, results []AccountResult, useColor bool) { setGlobalColor(useColor) for i, r := range results { if i > 0 { fmt.Fprintln(w) } if r.Err != nil { RenderAccountError(w, r.Name, r.Err, useColor) continue } RenderAccount(w, r.Name, r.Response, useColor) } } // setGlobalColor flips fatih/color's global NoColor flag. // The package is designed around this global; localizing it would require // wrapping every Sprint call, which the codebase is too small to justify. func setGlobalColor(useColor bool) { color.NoColor = !useColor } // RenderAccount writes the header + two quota rows + token summary + monthly // cap line for a single account. Caller must have already called // setGlobalColor via RenderAll, or set color.NoColor directly. func RenderAccount(w io.Writer, name string, resp *api.Response, useColor bool) { writeHeader(w, name) writePlanLine(w, resp) fmt.Fprintln(w) d := resp.Data rows := []struct { label string window api.QuotaWindow }{ {"5 hour", d.Quota5Hour}, {"7 day", d.Quota7Day}, } // Pre-format each row's segments so we can align by max width. type formatted struct { label, bar, pct, flows, dollars string pctAttr color.Attribute } fmts := make([]formatted, len(rows)) for i, r := range rows { f := formatted{label: r.label} f.bar = ProgressBar(r.window.UsagePercentage, barWidth) f.pct = fmt.Sprintf("%6.2f%%", r.window.UsagePercentage*100) f.pctAttr = BandColor(r.window.UsagePercentage) f.flows = fmt.Sprintf("%s / %s flows", fmtFlow(r.window.UsedFlows), fmtFlow(r.window.MaxFlows)) f.dollars = fmt.Sprintf("%s / %s", fmtUSD(r.window.UsedValueUSD), fmtUSD(r.window.MaxValueUSD)) fmts[i] = f } flowsW, dollarsW := 0, 0 for _, f := range fmts { if len(f.flows) > flowsW { flowsW = len(f.flows) } if len(f.dollars) > dollarsW { dollarsW = len(f.dollars) } } for _, f := range fmts { bar := color.New(f.pctAttr).Sprint(f.bar) pct := color.New(f.pctAttr).Sprint(f.pct) fmt.Fprintf(w, " %-7s [%s] %s %-*s %-*s\n", f.label, bar, pct, flowsW, f.flows, dollarsW, f.dollars) } fmt.Fprintln(w) fmt.Fprintf(w, " Tokens consumed (estimated USD value): %s\n", fmtUSD(d.Quota7Day.UsedValueUSD)) fmt.Fprintf(w, " Monthly cap: %s flows · %s\n", fmtFlow(d.QuotaMonthly.MaxFlows), fmtUSD(d.QuotaMonthly.MaxValueUSD)) resets := resetsLine(d.Quota5Hour.ResetsAt, d.Quota7Day.ResetsAt) if resets != "" { fmt.Fprintf(w, " Next reset: %s\n", resets) } } // RenderAccountError writes the header block and a single red error line, // then returns. Used for per-account failures in multi-account runs. func RenderAccountError(w io.Writer, name string, err error, useColor bool) { writeHeader(w, name) msg := color.New(color.FgRed).Sprintf(" error: %v", err) fmt.Fprintln(w, msg) } func writeHeader(w io.Writer, name string) { label := fmt.Sprintf("━━━ %s ━━━", name) fmt.Fprintln(w, color.New(color.Bold).Sprint(label)) } func writePlanLine(w io.Writer, resp *api.Response) { d := resp.Data tier := titleCase(d.Plan.Tier) status := colorizeStatus(d.AccountStatus) fmt.Fprintf(w, "ZenMux Subscription — %s plan ($%.0f/mo) · %s · $%.5f/flow\n", tier, d.Plan.AmountUSD, status, d.EffectiveUSDPerFlow) } // resetsLine composes "5h → 2026-04-22 14:05 · 7d → 2026-04-28 00:00". // Missing or unparseable timestamps are skipped silently. func resetsLine(fiveHour, sevenDay *string) string { parts := make([]string, 0, 2) if s := formatReset(fiveHour); s != "" { parts = append(parts, "5h → "+s) } if s := formatReset(sevenDay); s != "" { parts = append(parts, "7d → "+s) } return strings.Join(parts, " · ") } func formatReset(s *string) string { if s == nil || *s == "" { return "" } t, err := time.Parse(time.RFC3339, *s) if err != nil { return "" } return t.Local().Format("2006-01-02 15:04") } func colorizeStatus(status string) string { switch status { case "healthy": return color.New(color.FgGreen).Sprint(status) case "monitored": return color.New(color.FgYellow).Sprint(status) case "abusive", "suspended", "banned": return color.New(color.FgRed).Sprint(status) default: return status } } func titleCase(s string) string { if s == "" { return s } return strings.ToUpper(s[:1]) + s[1:] } // fmtFlow prints a flow count trimming trailing zeros but keeping // at most 2 decimal places so 57.2 stays "57.2", 800 stays "800", // and 416.11 stays "416.11". func fmtFlow(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) } func fmtUSD(v float64) string { return fmt.Sprintf("$%.2f", v) }