package ui import ( "fmt" "html/template" "strconv" "strings" "time" ) // funcMap returns the template functions every page can call. // Kept small on purpose: anything fancier belongs in the handler, // which can pre-compute and pass primitives into the view. func funcMap() template.FuncMap { return template.FuncMap{ "bytes": formatBytes, "relTime": formatRelTime, "comma": formatComma, "deref": derefStr, "timeNotZero": func(t *time.Time) bool { return t != nil && !t.IsZero() }, } } // formatBytes renders a byte count as a short human string — // "412 GB", "3.7 TB", "8.4 GB". Single decimal place for sub-1000 // values, none above. Returns "—" for zero so the dashboard's // "never run" rows read clean. func formatBytes(n int64) template.HTML { if n == 0 { return template.HTML(``) } const ( kb = 1000 mb = 1000 * kb gb = 1000 * mb tb = 1000 * gb ) var ( val float64 unit string ) switch { case n >= tb: val, unit = float64(n)/float64(tb), "TB" case n >= gb: val, unit = float64(n)/float64(gb), "GB" case n >= mb: val, unit = float64(n)/float64(mb), "MB" case n >= kb: val, unit = float64(n)/float64(kb), "kB" default: return template.HTML(fmt.Sprintf(`%d B`, n)) } num := strconv.FormatFloat(val, 'f', -1, 64) if val < 100 && !strings.Contains(num, ".") { num += ".0" } else if val < 100 && strings.Contains(num, ".") { // One decimal max, e.g. "3.7" not "3.74". idx := strings.Index(num, ".") if len(num) > idx+2 { num = num[:idx+2] } } else { // Above 100, no decimals — "412" not "412.4". if idx := strings.Index(num, "."); idx > 0 { num = num[:idx] } } return template.HTML(fmt.Sprintf(`%s %s`, num, unit)) } // formatRelTime renders a time as a short relative string like // "3m ago" / "2d ago" / "5w ago". Future times render as // "in 5m"-style. Accepts *time.Time or time.Time so templates can // pass either without fighting Go's lack of an address-of operator. // Anything else returns "—". func formatRelTime(v any) string { var t time.Time switch x := v.(type) { case time.Time: t = x case *time.Time: if x == nil { return "—" } t = *x default: return "—" } if t.IsZero() { return "—" } d := time.Since(t) suffix := "ago" if d < 0 { d = -d suffix = "from now" } switch { case d < time.Minute: return fmt.Sprintf("%ds %s", int(d.Seconds()), suffix) case d < time.Hour: return fmt.Sprintf("%dm %s", int(d.Minutes()), suffix) case d < 24*time.Hour: return fmt.Sprintf("%dh %s", int(d.Hours()), suffix) case d < 7*24*time.Hour: return fmt.Sprintf("%dd %s", int(d.Hours()/24), suffix) default: return fmt.Sprintf("%dw %s", int(d.Hours()/(24*7)), suffix) } } // formatComma renders 1847 as "1,847". Used for snapshot counts and // any other count that benefits from grouping at this scale. func formatComma(n int) string { s := strconv.Itoa(n) if n < 1000 && n > -1000 { return s } // Hand-rolled grouping; Go has no builtin and we don't want a // dep for this. Negative numbers handled by stripping the sign, // grouping, then putting it back. neg := false if s[0] == '-' { neg = true s = s[1:] } var b strings.Builder for i, c := range s { if i > 0 && (len(s)-i)%3 == 0 { b.WriteByte(',') } b.WriteRune(c) } if neg { return "-" + b.String() } return b.String() } // derefStr returns the string a pointer points to, or "" if nil. // Avoids "" appearing in templates that hit a missing FK. func derefStr(p *string) string { if p == nil { return "" } return *p }