Files
kjol/go/webui/formatters.go

307 lines
8.9 KiB
Go

// Port of web/kit/Formatters.ts.
package webui
import (
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
)
// formattersNonDigit matches every non-digit rune (the JS /\D/g).
var formattersNonDigit = regexp.MustCompile(`\D`)
// formattersDigits strips every non-digit character (mirrors String(n).replace(/\D/g, "")).
func formattersDigits(s string) string {
return formattersNonDigit.ReplaceAllString(s, "")
}
// formattersStringify mimics JavaScript's String(v) for the `string | number`
// union accepted by several formatters. Go has no union type, so these take
// `any` and stringify int / float / string inputs the way JS would.
func formattersStringify(v any) string {
switch n := v.(type) {
case string:
return n
case int:
return strconv.Itoa(n)
case int8, int16, int32, int64:
return fmt.Sprintf("%d", n)
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprintf("%d", n)
case float32:
return strconv.FormatFloat(float64(n), 'f', -1, 32)
case float64:
return strconv.FormatFloat(n, 'f', -1, 64)
default:
return fmt.Sprintf("%v", v)
}
}
// formattersPadStartZero left-pads s with '0' up to length (the JS padStart(n, "0")).
func formattersPadStartZero(s string, length int) string {
if len(s) >= length {
return s
}
return strings.Repeat("0", length-len(s)) + s
}
// formattersParseInt approximates JS parseInt(s, 10): optional sign then leading
// decimal digits. JS would yield NaN for non-numeric input; here we return 0.
func formattersParseInt(s string) int {
s = strings.TrimSpace(s)
i := 0
neg := false
if i < len(s) && (s[i] == '+' || s[i] == '-') {
neg = s[i] == '-'
i++
}
start := i
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if start == i {
return 0
}
n, err := strconv.Atoi(s[start:i])
if err != nil {
return 0
}
if neg {
return -n
}
return n
}
// FormatPhoneNumber formats a number as a US phone number: (XXX) XXX-XXXX.
func FormatPhoneNumber(number any) string {
digits := formattersPadStartZero(formattersDigits(formattersStringify(number)), 10)[:10]
areaCode := digits[0:3]
centralOfficeCode := digits[3:6]
lineNumber := digits[6:10]
return "(" + areaCode + ") " + centralOfficeCode + "-" + lineNumber
}
// FormatZipCode formats a number as a US zip code (5 or 9 digits).
func FormatZipCode(number any) string {
num := formattersParseInt(formattersStringify(number))
if num <= 99999 {
return formattersPadStartZero(strconv.Itoa(num), 5)
}
digits := formattersPadStartZero(strconv.Itoa(num), 9)
zipCode := digits[0:5]
plus4 := digits[5:9]
return zipCode + "-" + plus4
}
// FormatTaxID formats a number as a US Tax ID (EIN): XX-XXXXXXX.
func FormatTaxID(number any) string {
digits := formattersPadStartZero(formattersDigits(formattersStringify(number)), 9)[:9]
prefix := digits[0:2]
identifier := digits[2:9]
return prefix + "-" + identifier
}
// formattersGroupThousands inserts ',' every three digits from the right of an
// integer-digit string (no sign, no fraction).
func formattersGroupThousands(intDigits string) string {
n := len(intDigits)
if n <= 3 {
return intDigits
}
var b strings.Builder
pre := n % 3
if pre > 0 {
b.WriteString(intDigits[:pre])
}
for i := pre; i < n; i += 3 {
if b.Len() > 0 {
b.WriteByte(',')
}
b.WriteString(intDigits[i : i+3])
}
return b.String()
}
// formattersFormatGrouped renders n like Intl.NumberFormat("en-US") with the
// given minimum/maximum fraction digits: rounds to maxFrac, trims trailing zeros
// down to minFrac, and groups the integer part with commas.
func formattersFormatGrouped(n float64, minFrac, maxFrac int) string {
neg := math.Signbit(n)
s := strconv.FormatFloat(math.Abs(n), 'f', maxFrac, 64)
intPart, fracPart := s, ""
if dot := strings.IndexByte(s, '.'); dot >= 0 {
intPart = s[:dot]
fracPart = s[dot+1:]
}
for len(fracPart) > minFrac && strings.HasSuffix(fracPart, "0") {
fracPart = fracPart[:len(fracPart)-1]
}
intPart = formattersGroupThousands(intPart)
res := intPart
if len(fracPart) > 0 {
res += "." + fracPart
}
// Only keep the sign when the rounded result is actually non-zero (avoids "-0").
if neg && strings.ContainsFunc(res, func(r rune) bool { return r >= '1' && r <= '9' }) {
res = "-" + res
}
return res
}
// FormatNumber formats a number with US thousands separators (Intl.NumberFormat
// "en-US" defaults: 0 minimum and 3 maximum fraction digits).
func FormatNumber(number float64) string {
return formattersFormatGrouped(number, 0, 3)
}
// FormatDecimal formats a number with US thousands separators and a fixed number
// of fraction digits (default 2). Go has no default parameters, so decimalPlaces
// is an optional variadic argument.
func FormatDecimal(number float64, decimalPlaces ...int) string {
dp := 2
if len(decimalPlaces) > 0 {
dp = decimalPlaces[0]
}
return formattersFormatGrouped(number, dp, dp)
}
// State Code Utilities
var formattersStateCodeMap = map[string]string{
"Alabama": "AL", "Alaska": "AK", "Arizona": "AZ", "Arkansas": "AR", "California": "CA",
"Colorado": "CO", "Connecticut": "CT", "Delaware": "DE", "District of Columbia": "DC", "Florida": "FL",
"Georgia": "GA", "Hawaii": "HI", "Idaho": "ID", "Illinois": "IL", "Indiana": "IN",
"Iowa": "IA", "Kansas": "KS", "Kentucky": "KY", "Louisiana": "LA", "Maine": "ME",
"Maryland": "MD", "Massachusetts": "MA", "Michigan": "MI", "Minnesota": "MN", "Mississippi": "MS",
"Missouri": "MO", "Montana": "MT", "Nebraska": "NE", "Nevada": "NV", "New Hampshire": "NH",
"New Jersey": "NJ", "New Mexico": "NM", "New York": "NY", "North Carolina": "NC", "North Dakota": "ND",
"Ohio": "OH", "Oklahoma": "OK", "Oregon": "OR", "Pennsylvania": "PA", "Puerto Rico": "PR",
"Rhode Island": "RI", "South Carolina": "SC", "South Dakota": "SD", "Tennessee": "TN", "Texas": "TX",
"Utah": "UT", "Vermont": "VT", "Virgin Islands": "VI", "Virginia": "VA", "Washington": "WA",
"West Virginia": "WV", "Wisconsin": "WI", "Wyoming": "WY",
}
// StateToStateCode resolves a state name to its two-letter code, matching the
// exact name first and then case-insensitively. Returns "" when unknown.
func StateToStateCode(state string) string {
if code, ok := formattersStateCodeMap[state]; ok {
return code
}
stateLower := strings.ToLower(state)
for stateName, code := range formattersStateCodeMap {
if strings.ToLower(stateName) == stateLower {
return code
}
}
return ""
}
// StateCodeToState resolves a two-letter code to its state name. Returns "" when unknown.
func StateCodeToState(code string) string {
up := strings.ToUpper(code)
for state, stateCode := range formattersStateCodeMap {
if stateCode == up {
return state
}
}
return ""
}
// IsValidStateCode reports whether code is a known two-letter state code.
func IsValidStateCode(code string) bool {
up := strings.ToUpper(code)
for _, stateCode := range formattersStateCodeMap {
if stateCode == up {
return true
}
}
return false
}
// formattersDateLayouts are the layouts tried when coercing a string into a
// time.Time, standing in for JS `new Date(string)` (which cannot be reproduced
// exactly). Ordered most-specific first.
var formattersDateLayouts = []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04:05",
"2006-01-02",
"2006/01/02",
"01/02/2006",
time.RFC1123Z,
time.RFC1123,
time.ANSIC,
}
// formattersToTime coerces the `string | Date` union into a time.Time. The bool
// is false when a string could not be parsed (JS would produce "Invalid Date").
func formattersToTime(date any) (time.Time, bool) {
switch d := date.(type) {
case time.Time:
return d, true
case string:
for _, layout := range formattersDateLayouts {
if t, err := time.Parse(layout, d); err == nil {
return t, true
}
}
return time.Time{}, false
default:
return time.Time{}, false
}
}
// FormatDate formats a date as MM/DD/YYYY. Returns "" for an unparseable string.
func FormatDate(date any) string {
d, ok := formattersToTime(date)
if !ok {
return ""
}
return d.Format("01/02/2006")
}
// FormatDateLong formats a date as e.g. "January 2, 2006". Returns "" for an
// unparseable string.
func FormatDateLong(date any) string {
d, ok := formattersToTime(date)
if !ok {
return ""
}
return d.Format("January 2, 2006")
}
// FormatDateTime formats a date+time as e.g. "01/02/2006, 3:04 PM". Returns ""
// for an unparseable string.
func FormatDateTime(date any) string {
d, ok := formattersToTime(date)
if !ok {
return ""
}
return d.Format("01/02/2006, 3:04 PM")
}
// FormatPercent formats a value as a percentage with a fixed number of decimal
// places. When isDecimal is true the value is treated as a fraction (0-1) and
// scaled by 100. Go has no default parameters, so decimalPlaces and isDecimal
// (defaults 2 and false in the TS source) are required here.
func FormatPercent(value float64, decimalPlaces int, isDecimal bool) string {
percent := value
if isDecimal {
percent = value * 100
}
return strconv.FormatFloat(percent, 'f', decimalPlaces, 64) + "%"
}