Fix duration pretty printing and number shortening

This commit is contained in:
Noah Petherbridge 2023-12-21 17:12:34 -08:00
parent 8863daac60
commit b43fec144b
7 changed files with 294 additions and 21 deletions

View File

@ -46,7 +46,7 @@ const (
) )
// Number of expected scopes for unit test and validation. // Number of expected scopes for unit test and validation.
const QuantityAdminScopes = 14 const QuantityAdminScopes = 16
// The specially named Superusers group. // The specially named Superusers group.
const AdminGroupSuperusers = "Superusers" const AdminGroupSuperusers = "Superusers"

View File

@ -29,6 +29,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
return template.FuncMap{ return template.FuncMap{
"InputCSRF": InputCSRF(r), "InputCSRF": InputCSRF(r),
"SincePrettyCoarse": SincePrettyCoarse(), "SincePrettyCoarse": SincePrettyCoarse(),
"FormatNumberShort": FormatNumberShort(),
"ComputeAge": utility.Age, "ComputeAge": utility.Age,
"Split": strings.Split, "Split": strings.Split,
"ToMarkdown": ToMarkdown, "ToMarkdown": ToMarkdown,
@ -109,6 +110,20 @@ func SincePrettyCoarse() func(time.Time) template.HTML {
} }
} }
// FormatNumberShort will format any integer number into a short representation.
func FormatNumberShort() func(v interface{}) template.HTML {
return func(v interface{}) template.HTML {
var number int64
switch v.(type) {
case int, int64, uint, uint64:
number = v.(int64)
default:
return template.HTML("#INVALID#")
}
return template.HTML(utility.FormatNumberShort(number))
}
}
// ToMarkdown renders input text as Markdown. // ToMarkdown renders input text as Markdown.
func ToMarkdown(input string) template.HTML { func ToMarkdown(input string) template.HTML {
return template.HTML(markdown.Render(input)) return template.HTML(markdown.Render(input))

View File

@ -0,0 +1,40 @@
package utility
import (
"fmt"
"strconv"
"strings"
)
// FormatNumberShort compresses a number into as short a string as possible (e.g. "1.2K" when it gets into the thousands).
func FormatNumberShort(value int64) string {
// Under 1,000?
if value < 1000 {
return fmt.Sprintf("%d", value)
}
// Start to bucket it.
var (
thousands = float64(value) / 1000
millions = float64(thousands) / 1000
billions = float64(millions) / 1000
)
formatFloat := func(v float64) string {
s := strconv.FormatFloat(v, 'f', 1, 64)
if strings.HasSuffix(s, ".0") {
return strings.Split(s, ".")[0]
}
return s
}
if thousands < 1000 {
return fmt.Sprintf("%sK", formatFloat(thousands))
}
if millions < 1000 {
return fmt.Sprintf("%sM", formatFloat(millions))
}
return fmt.Sprintf("%sB", formatFloat(billions))
}

View File

@ -0,0 +1,66 @@
package utility_test
import (
"testing"
"code.nonshy.com/nonshy/website/pkg/utility"
)
func TestNumberFormat(t *testing.T) {
var tests = []struct {
In int64
Expect string
}{
{0, "0"},
{1, "1"},
{10, "10"},
{15, "15"},
{92, "92"},
{100, "100"},
{101, "101"},
{150, "150"},
{200, "200"},
{404, "404"},
{867, "867"},
{990, "990"},
{999, "999"},
{1000, "1K"},
{1001, "1K"},
{1010, "1K"},
{1100, "1.1K"},
{1111, "1.1K"},
{1200, "1.2K"},
{1500, "1.5K"},
{1700, "1.7K"},
{1849, "1.8K"},
{1850, "1.9K"},
{1899, "1.9K"},
{1900, "1.9K"},
{12000, "12K"},
{12300, "12.3K"},
{900000, "900K"},
{900100, "900.1K"},
{999100, "999.1K"},
{999500, "999.5K"},
{999999, "1000K"}, // TODO: not ideal
{1000000, "1M"},
{1001000, "1M"},
{1005000, "1M"},
{1010000, "1M"},
{1100000, "1.1M"},
{1200000, "1.2M"},
{1305000, "1.3M"},
{1350000, "1.4M"},
{1400000, "1.4M"},
{100000000, "100M"},
{990509000, "990.5M"},
{1000000000, "1B"},
}
for _, test := range tests {
actual := utility.FormatNumberShort(test.In)
if actual != test.Expect {
t.Errorf("Expected %d to be '%s' but got '%s'", test.In, test.Expect, actual)
}
}
}

View File

@ -2,6 +2,7 @@ package utility
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
"time" "time"
) )
@ -38,10 +39,16 @@ func FormatDurationCoarse(duration time.Duration) string {
} }
months := int64(days / 30) months := int64(days / 30)
if months <= 12 { if months < 12 {
return result("%d months", months) return result("%d months", months)
} }
years := int64(days / 365) // Over one year: start to show it as a floating point number of years (e.g. "1.2 years")
return result("%d years", years) years := float64(days) / 365
s := strconv.FormatFloat(years, 'f', 1, 64)
if strings.HasSuffix(s, ".0") {
y, _ := strconv.Atoi(strings.Split(s, ".")[0])
return result("%d years", int64(y))
}
return fmt.Sprintf("%s years", s)
} }

143
pkg/utility/time_test.go Normal file
View File

@ -0,0 +1,143 @@
package utility_test
import (
"testing"
"time"
"code.nonshy.com/nonshy/website/pkg/utility"
)
func TestFormatDurationCoarse(t *testing.T) {
var tests = []struct {
In time.Duration
Expect string
}{
{
In: 0,
Expect: "0 seconds",
},
{
In: 1 * time.Second,
Expect: "1 second",
},
{
In: 2 * time.Second,
Expect: "2 seconds",
},
{
In: 25 * time.Second,
Expect: "25 seconds",
},
{
In: 59 * time.Second,
Expect: "59 seconds",
},
{
In: 60 * time.Second,
Expect: "1 minute",
},
{
In: 90 * time.Second,
Expect: "1 minute",
},
{
In: 119 * time.Second,
Expect: "1 minute",
},
{
In: 120 * time.Second,
Expect: "2 minutes",
},
{
In: 15 * time.Minute,
Expect: "15 minutes",
},
{
In: 59 * time.Minute,
Expect: "59 minutes",
},
{
In: 60 * time.Minute,
Expect: "1 hour",
},
{
In: 75 * time.Minute,
Expect: "1 hour",
},
{
In: 119 * time.Minute,
Expect: "1 hour",
},
{
In: 120 * time.Minute,
Expect: "2 hours",
},
{
In: 14 * time.Hour,
Expect: "14 hours",
},
{
In: 23 * time.Hour,
Expect: "23 hours",
},
{
In: 24 * time.Hour,
Expect: "1 day",
},
{
In: 36 * time.Hour,
Expect: "1 day",
},
{
In: 48 * time.Hour,
Expect: "2 days",
},
{
In: time.Hour * 24 * 60,
Expect: "2 months",
},
{
In: time.Hour * 24 * 365,
Expect: "1 year",
},
{
In: time.Hour * 24 * 30 * 12,
Expect: "1 year",
},
{
In: time.Hour*24*30*12 + time.Hour*12,
Expect: "1 year",
},
{
In: time.Hour * 24 * 30 * 13,
Expect: "1.1 years",
},
{
In: time.Hour * 24 * 30 * 18,
Expect: "1.5 years",
},
{
In: time.Hour * 24 * 30 * 22,
Expect: "1.8 years",
},
{
In: time.Hour * 24 * 30 * 24,
Expect: "2 years",
},
{
In: time.Hour * 24 * 30 * 36,
Expect: "3 years",
},
{
In: time.Hour * 24 * 30 * 49,
Expect: "4 years",
},
}
for _, test := range tests {
actual := utility.FormatDurationCoarse(test.In)
if actual != test.Expect {
t.Errorf("Expected %d to be '%s' but got '%s'", test.In, test.Expect, actual)
}
}
}

View File

@ -74,13 +74,13 @@
<a class="navbar-item px-1" href="/friends{{if gt .NavFriendRequests 0}}?view=requests{{end}}"> <a class="navbar-item px-1" href="/friends{{if gt .NavFriendRequests 0}}?view=requests{{end}}">
<span class="icon"><i class="fa fa-user-group"></i></span> <span class="icon"><i class="fa fa-user-group"></i></span>
<span>Friends</span> <span>Friends</span>
{{if .NavFriendRequests}}<span class="nonshy-navbar-notification-tag is-warning ml-1">{{.NavFriendRequests}}</span>{{end}} {{if .NavFriendRequests}}<span class="nonshy-navbar-notification-tag is-warning ml-1">{{FormatNumberShort .NavFriendRequests}}</span>{{end}}
</a> </a>
<a class="navbar-item px-1" href="/messages"> <a class="navbar-item px-1" href="/messages">
<span class="icon"><i class="fa fa-envelope"></i></span> <span class="icon"><i class="fa fa-envelope"></i></span>
<span>Messages</span> <span>Messages</span>
{{if .NavUnreadMessages}}<span class="nonshy-navbar-notification-tag is-warning ml-1">{{.NavUnreadMessages}}</span>{{end}} {{if .NavUnreadMessages}}<span class="nonshy-navbar-notification-tag is-warning ml-1">{{FormatNumberShort .NavUnreadMessages}}</span>{{end}}
</a> </a>
<a class="navbar-item px-1" href="/members"> <a class="navbar-item px-1" href="/members">
@ -140,14 +140,16 @@
</div> </div>
<div class="column"> <div class="column">
{{.CurrentUser.Username}} {{.CurrentUser.Username}}
{{if .NavUnreadNotifications}}<span class="nonshy-navbar-notification-tag is-warning ml-1">{{.NavUnreadNotifications}}</span>{{end}} {{if .NavUnreadNotifications}}
<span class="nonshy-navbar-notification-tag is-warning ml-1">{{FormatNumberShort .NavUnreadNotifications}}</span>
{{end}}
{{if .NavAdminNotifications}} {{if .NavAdminNotifications}}
{{if and (.NavCertificationPhotos) (not .NavAdminFeedback)}} {{if and (.NavCertificationPhotos) (not .NavAdminFeedback)}}
<span class="nonshy-navbar-notification-tag is-success ml-1">{{.NavAdminNotifications}}</span> <span class="nonshy-navbar-notification-tag is-success ml-1">{{FormatNumberShort .NavAdminNotifications}}</span>
{{else if and .NavCertificationPhotos .NavAdminFeedback}} {{else if and .NavCertificationPhotos .NavAdminFeedback}}
<span class="nonshy-navbar-notification-tag is-mixed ml-1">{{.NavAdminNotifications}}</span> <span class="nonshy-navbar-notification-tag is-mixed ml-1">{{FormatNumberShort .NavAdminNotifications}}</span>
{{else}} {{else}}
<span class="nonshy-navbar-notification-tag is-danger ml-1">{{.NavAdminNotifications}}</span> <span class="nonshy-navbar-notification-tag is-danger ml-1">{{FormatNumberShort .NavAdminNotifications}}</span>
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
@ -161,7 +163,7 @@
{{if .NavUnreadNotifications}} {{if .NavUnreadNotifications}}
<span class="nonshy-navbar-notification-tag is-warning ml-1"> <span class="nonshy-navbar-notification-tag is-warning ml-1">
<span class="icon"><i class="fa fa-bell"></i></span> <span class="icon"><i class="fa fa-bell"></i></span>
<span>{{.NavUnreadNotifications}}</span> <span>{{FormatNumberShort .NavUnreadNotifications}}</span>
</span> </span>
{{end}} {{end}}
</a> </a>
@ -202,11 +204,11 @@
{{if .NavAdminNotifications}} {{if .NavAdminNotifications}}
<!-- Color code them by the type --> <!-- Color code them by the type -->
{{if and (.NavCertificationPhotos) (not .NavAdminFeedback)}} {{if and (.NavCertificationPhotos) (not .NavAdminFeedback)}}
<span class="nonshy-navbar-notification-tag is-success ml-1">{{.NavAdminNotifications}}</span> <span class="nonshy-navbar-notification-tag is-success ml-1">{{FormatNumberShort .NavAdminNotifications}}</span>
{{else if and .NavCertificationPhotos .NavAdminFeedback}} {{else if and .NavCertificationPhotos .NavAdminFeedback}}
<span class="nonshy-navbar-notification-tag is-mixed ml-1">{{.NavAdminNotifications}}</span> <span class="nonshy-navbar-notification-tag is-mixed ml-1">{{FormatNumberShort .NavAdminNotifications}}</span>
{{else}} {{else}}
<span class="nonshy-navbar-notification-tag is-danger ml-1">{{.NavAdminNotifications}}</span> <span class="nonshy-navbar-notification-tag is-danger ml-1">{{FormatNumberShort .NavAdminNotifications}}</span>
{{end}} {{end}}
{{end}} {{end}}
</a> </a>
@ -247,7 +249,7 @@
href="/chat"> href="/chat">
<span class="icon"><i class="fa fa-message"></i></span> <span class="icon"><i class="fa fa-message"></i></span>
{{if gt .NavChatStatistics.UserCount 0}} {{if gt .NavChatStatistics.UserCount 0}}
<small class="nonshy-navbar-notification-count">{{.NavChatStatistics.UserCount}}</small> <small class="nonshy-navbar-notification-count">{{FormatNumberShort .NavChatStatistics.UserCount}}</small>
{{end}} {{end}}
</a> </a>
@ -266,7 +268,7 @@
href="/friends{{if gt .NavFriendRequests 0}}?view=requests{{end}}"> href="/friends{{if gt .NavFriendRequests 0}}?view=requests{{end}}">
<span class="icon"><i class="fa fa-user-group"></i></span> <span class="icon"><i class="fa fa-user-group"></i></span>
{{if gt .NavFriendRequests 0}} {{if gt .NavFriendRequests 0}}
<small class="nonshy-navbar-notification-count">{{.NavFriendRequests}}</small> <small class="nonshy-navbar-notification-count">{{FormatNumberShort .NavFriendRequests}}</small>
{{end}} {{end}}
</a> </a>
@ -274,14 +276,14 @@
<a class="tag {{if gt .NavUnreadMessages 0}}is-warning{{else}}is-grey{{end}} py-4" href="/messages"> <a class="tag {{if gt .NavUnreadMessages 0}}is-warning{{else}}is-grey{{end}} py-4" href="/messages">
<span class="icon"><i class="fa fa-envelope"></i></span> <span class="icon"><i class="fa fa-envelope"></i></span>
{{if gt .NavUnreadMessages 0}} {{if gt .NavUnreadMessages 0}}
<small class="nonshy-navbar-notification-count">{{.NavUnreadMessages}}</small> <small class="nonshy-navbar-notification-count">{{FormatNumberShort .NavUnreadMessages}}</small>
{{end}} {{end}}
</a> </a>
{{if gt .NavUnreadNotifications 0}} {{if gt .NavUnreadNotifications 0}}
<a class="tag is-warning py-4" href="/me#notifications"> <a class="tag is-warning py-4" href="/me#notifications">
<span class="icon"><i class="fa fa-bell"></i></span> <span class="icon"><i class="fa fa-bell"></i></span>
<small class="nonshy-navbar-notification-count">{{.NavUnreadNotifications}}</small> <small class="nonshy-navbar-notification-count">{{FormatNumberShort .NavUnreadNotifications}}</small>
</a> </a>
{{end}} {{end}}
@ -290,17 +292,17 @@
{{if and (.NavCertificationPhotos) (not .NavAdminFeedback)}} {{if and (.NavCertificationPhotos) (not .NavAdminFeedback)}}
<a class="tag is-success py-4" href="/admin"> <a class="tag is-success py-4" href="/admin">
<span class="icon"><i class="fa fa-peace"></i></span> <span class="icon"><i class="fa fa-peace"></i></span>
<small class="nonshy-navbar-notification-count">{{.NavAdminNotifications}}</small> <small class="nonshy-navbar-notification-count">{{FormatNumberShort .NavAdminNotifications}}</small>
</a> </a>
{{else if and .NavCertificationPhotos .NavAdminFeedback}} {{else if and .NavCertificationPhotos .NavAdminFeedback}}
<a class="tag is-mixed py-4" href="/admin"> <a class="tag is-mixed py-4" href="/admin">
<span class="icon"><i class="fa fa-peace"></i></span> <span class="icon"><i class="fa fa-peace"></i></span>
<small class="nonshy-navbar-notification-count">{{.NavAdminNotifications}}</small> <small class="nonshy-navbar-notification-count">{{FormatNumberShort .NavAdminNotifications}}</small>
</a> </a>
{{else}} {{else}}
<a class="tag is-danger py-4" href="/admin"> <a class="tag is-danger py-4" href="/admin">
<span class="icon"><i class="fa fa-peace"></i></span> <span class="icon"><i class="fa fa-peace"></i></span>
<small class="nonshy-navbar-notification-count">{{.NavAdminNotifications}}</small> <small class="nonshy-navbar-notification-count">{{FormatNumberShort .NavAdminNotifications}}</small>
</a> </a>
{{end}} {{end}}
{{end}} {{end}}