Various minor tweaks

* Demographics page:
    * Show percents with up to 1 decimal place of precision.
    * On tablets+ align the percent text to the right.
    * On photo counts, only include certified active user photos.
    * On gender/orientation demographics, pad the remaining "No answer" counts
      with the set of users who have no profile_fields set in the database yet.
* Admin certification page:
    * Add additional "common rejection reasons"
    * Add a confirm prompt when viewing the Rejected list to avoid accidental
      approval of previously rejected cert photos.
This commit is contained in:
Noah Petherbridge 2024-09-27 17:37:45 -07:00
parent 0c7fc7e866
commit 3fdae1d8d7
5 changed files with 81 additions and 47 deletions

View File

@ -12,6 +12,7 @@ import (
"time" "time"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/utility"
) )
// Demographic is the top level container struct with all the insights needed for front-end display. // Demographic is the top level container struct with all the insights needed for front-end display.
@ -43,7 +44,7 @@ type People struct {
type MemberDemographic struct { type MemberDemographic struct {
Label string // e.g. age range "18-25" or gender Label string // e.g. age range "18-25" or gender
Count int64 Count int64
Percent int Percent string
} }
/** /**
@ -58,32 +59,32 @@ func (d Demographic) PrettyPrint() string {
return string(b) return string(b)
} }
func (p Photo) PercentExplicit() int { func (p Photo) PercentExplicit() string {
if p.Total == 0 { if p.Total == 0 {
return 0 return "0"
} }
return int((float64(p.Explicit) / float64(p.Total)) * 100) return utility.FormatFloatToPrecision((float64(p.Explicit)/float64(p.Total))*100, 1)
} }
func (p Photo) PercentNonExplicit() int { func (p Photo) PercentNonExplicit() string {
if p.Total == 0 { if p.Total == 0 {
return 0 return "0"
} }
return int((float64(p.NonExplicit) / float64(p.Total)) * 100) return utility.FormatFloatToPrecision((float64(p.NonExplicit)/float64(p.Total))*100, 1)
} }
func (p People) PercentExplicit() int { func (p People) PercentExplicit() string {
if p.Total == 0 { if p.Total == 0 {
return 0 return "0"
} }
return int((float64(p.ExplicitOptIn) / float64(p.Total)) * 100) return utility.FormatFloatToPrecision((float64(p.ExplicitOptIn)/float64(p.Total))*100, 1)
} }
func (p People) PercentExplicitPhoto() int { func (p People) PercentExplicitPhoto() string {
if p.Total == 0 { if p.Total == 0 {
return 0 return "0"
} }
return int((float64(p.ExplicitPhoto) / float64(p.Total)) * 100) return utility.FormatFloatToPrecision((float64(p.ExplicitPhoto)/float64(p.Total))*100, 1)
} }
func (p People) IterAgeRanges() []MemberDemographic { func (p People) IterAgeRanges() []MemberDemographic {
@ -104,16 +105,16 @@ func (p People) IterAgeRanges() []MemberDemographic {
for _, age := range values { for _, age := range values {
var ( var (
count = p.ByAgeRange[age] count = p.ByAgeRange[age]
pct int pct float64
) )
if p.Total > 0 { if p.Total > 0 {
pct = int((float64(count) / float64(p.Total)) * 100) pct = ((float64(count) / float64(p.Total)) * 100)
} }
result = append(result, MemberDemographic{ result = append(result, MemberDemographic{
Label: age, Label: age,
Count: p.ByAgeRange[age], Count: p.ByAgeRange[age],
Percent: pct, Percent: utility.FormatFloatToPrecision(pct, 1),
}) })
} }
@ -141,16 +142,16 @@ func (p People) IterGenders() []MemberDemographic {
for _, gender := range values { for _, gender := range values {
var ( var (
count = p.ByGender[gender] count = p.ByGender[gender]
pct int pct float64
) )
if p.Total > 0 { if p.Total > 0 {
pct = int((float64(count) / float64(p.Total)) * 100) pct = ((float64(count) / float64(p.Total)) * 100)
} }
result = append(result, MemberDemographic{ result = append(result, MemberDemographic{
Label: gender, Label: gender,
Count: p.ByGender[gender], Count: p.ByGender[gender],
Percent: pct, Percent: utility.FormatFloatToPrecision(pct, 1),
}) })
} }
@ -178,16 +179,16 @@ func (p People) IterOrientations() []MemberDemographic {
for _, gender := range values { for _, gender := range values {
var ( var (
count = p.ByOrientation[gender] count = p.ByOrientation[gender]
pct int pct float64
) )
if p.Total > 0 { if p.Total > 0 {
pct = int((float64(count) / float64(p.Total)) * 100) pct = ((float64(count) / float64(p.Total)) * 100)
} }
result = append(result, MemberDemographic{ result = append(result, MemberDemographic{
Label: gender, Label: gender,
Count: p.ByOrientation[gender], Count: p.ByOrientation[gender],
Percent: pct, Percent: utility.FormatFloatToPrecision(pct, 1),
}) })
} }

View File

@ -70,8 +70,8 @@ func Generate() (Demographic, error) {
func PeopleStatistics() People { func PeopleStatistics() People {
var result = People{ var result = People{
ByAgeRange: map[string]int64{}, ByAgeRange: map[string]int64{},
ByGender: map[string]int64{}, ByGender: map[string]int64{"": 0},
ByOrientation: map[string]int64{}, ByOrientation: map[string]int64{"": 0},
} }
type record struct { type record struct {
@ -216,6 +216,11 @@ func PeopleStatistics() People {
} }
// Ingest the records. // Ingest the records.
var (
totalWithAge int64 // will be the total count of users since age is required
totalWithGender int64
totalWithOrientation int64
)
for _, row := range records { for _, row := range records {
switch row.MetricType { switch row.MetricType {
case "ExplicitCount": case "ExplicitCount":
@ -230,19 +235,27 @@ func PeopleStatistics() People {
result.ByAgeRange[row.MetricValue] = 0 result.ByAgeRange[row.MetricValue] = 0
} }
result.ByAgeRange[row.MetricValue] += row.MetricCount result.ByAgeRange[row.MetricValue] += row.MetricCount
totalWithAge += row.MetricCount
case "GenderCount": case "GenderCount":
if _, ok := result.ByGender[row.MetricValue]; !ok { if _, ok := result.ByGender[row.MetricValue]; !ok {
result.ByGender[row.MetricValue] = 0 result.ByGender[row.MetricValue] = 0
} }
result.ByGender[row.MetricValue] += row.MetricCount result.ByGender[row.MetricValue] += row.MetricCount
totalWithGender += row.MetricCount
case "OrientationCount": case "OrientationCount":
if _, ok := result.ByOrientation[row.MetricValue]; !ok { if _, ok := result.ByOrientation[row.MetricValue]; !ok {
result.ByOrientation[row.MetricValue] = 0 result.ByOrientation[row.MetricValue] = 0
} }
result.ByOrientation[row.MetricValue] += row.MetricCount result.ByOrientation[row.MetricValue] += row.MetricCount
totalWithOrientation += row.MetricCount
} }
} }
// Gender and Orientation: pad out the "no answer" selection with the count of users
// who had no profile_fields stored in the DB at all.
result.ByOrientation[""] += (totalWithAge - totalWithOrientation)
result.ByGender[""] += (totalWithAge - totalWithGender)
return result return result
} }
@ -263,8 +276,10 @@ func PhotoStatistics() Photo {
count(photos.id) AS c count(photos.id) AS c
FROM FROM
photos photos
WHERE JOIN users ON (photos.user_id = users.id)
photos.visibility = 'public' WHERE photos.visibility = 'public'
AND users.certified IS TRUE
AND users.status = 'active'
GROUP BY photos.explicit GROUP BY photos.explicit
ORDER BY c DESC ORDER BY c DESC
`).Scan(&records) `).Scan(&records)

View File

@ -6,6 +6,17 @@ import (
"strings" "strings"
) )
// FormatFloatToPrecision will trim a floating point number to at most a number of decimals of precision.
//
// If the precision is ".0" the decimal place will be stripped entirely.
func FormatFloatToPrecision(v float64, prec int) string {
s := strconv.FormatFloat(v, 'f', prec, 64)
if strings.HasSuffix(s, ".0") {
return strings.Split(s, ".")[0]
}
return s
}
// FormatNumberShort compresses a number into as short a string as possible (e.g. "1.2K" when it gets into the thousands). // 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 { func FormatNumberShort(value int64) string {
// Under 1,000? // Under 1,000?
@ -20,21 +31,13 @@ func FormatNumberShort(value int64) string {
billions = float64(millions) / 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 { if thousands < 1000 {
return fmt.Sprintf("%sK", formatFloat(thousands)) return fmt.Sprintf("%sK", FormatFloatToPrecision(thousands, 1))
} }
if millions < 1000 { if millions < 1000 {
return fmt.Sprintf("%sM", formatFloat(millions)) return fmt.Sprintf("%sM", FormatFloatToPrecision(millions, 1))
} }
return fmt.Sprintf("%sB", formatFloat(billions)) return fmt.Sprintf("%sB", FormatFloatToPrecision(billions, 1))
} }

View File

@ -177,9 +177,11 @@
<option value="Your certification pic should depict you holding onto a sheet of paper with your username, site name, and current date written on it."> <option value="Your certification pic should depict you holding onto a sheet of paper with your username, site name, and current date written on it.">
Didn't follow directions Didn't follow directions
</option> </option>
<option value="The sheet of paper must also include the website name: nonshy">Website name not visible</option> <option value="The sheet of paper must also include the website name: nonshy.">Website name not visible</option>
<option value="Please take a clearer picture that shows your arm and hand holding onto the sheet of paper">Unclear picture (hand not visible enough)</option> <option value="Please take a clearer picture that shows your arm and hand holding onto the sheet of paper.">Unclear picture (hand not visible enough)</option>
<option value="This photo has been digitally altered, please take a new certification picture and upload it as it comes off your camera">Photoshopped or digitally altered</option> <option value="This photo has been digitally altered, please take a new certification picture and upload it as it comes off your camera.">Photoshopped or digitally altered</option>
<option value="Your certification photo should feature a hand written message on paper so we know you're a real person. It looks like you added text digitally to this picture, which isn't acceptable because anybody could have downloaded a picture like this from online and added text to it in that way.\n\nPlease take a picture with your certification message hand written on paper and upload it how it came off the camera.">Text appears added digitally</option>
<option value="The sheet of paper that you are holding in this picture is not readable. Please take a clearer picture that shows you holding onto a sheet of paper which has the website's name (nonshy), your username, and today's date written and be sure that it is readable.">Cert message is not legible</option>
<option value="You had a previous account on nonshy which was suspended and you are not welcome with a new account.">User was previously banned from nonshy</option> <option value="You had a previous account on nonshy which was suspended and you are not welcome with a new account.">User was previously banned from nonshy</option>
<option value="This is not an acceptable certification photo.">Not acceptable</option> <option value="This is not an acceptable certification photo.">Not acceptable</option>
</optgroup> </optgroup>
@ -211,7 +213,8 @@
{{end}} {{end}}
{{if not (eq .Status "approved")}} {{if not (eq .Status "approved")}}
<button type="submit" name="verdict" value="approve" class="card-footer-item button is-success"> <button type="submit" name="verdict" value="approve" class="card-footer-item button is-success"
{{if eq $Root.View "rejected"}}onclick="return confirm('Are you SURE you want to mark this photo as Approved?\n\nKeep in mind you are currently viewing the Rejected list of photos!')"{{end}}>
<span class="icon"><i class="fa fa-check"></i></span> <span class="icon"><i class="fa fa-check"></i></span>
<span>Approve</span> <span>Approve</span>
</button> </button>
@ -245,7 +248,7 @@
textarea.addEventListener("change", setApproveState); textarea.addEventListener("change", setApproveState);
textarea.addEventListener("keyup", setApproveState); textarea.addEventListener("keyup", setApproveState);
elem.addEventListener("change", (e) => { elem.addEventListener("change", (e) => {
textarea.value = elem.value; textarea.value = elem.value.replaceAll("\\n", "\n");
setApproveState(); setApproveState();
}); });
}) })

View File

@ -92,7 +92,7 @@
{{.Demographic.Photo.PercentNonExplicit}}% {{.Demographic.Photo.PercentNonExplicit}}%
</progress> </progress>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow nonshy-percent-alignment">
{{.Demographic.Photo.PercentNonExplicit}}% {{.Demographic.Photo.PercentNonExplicit}}%
</div> </div>
</div> </div>
@ -109,7 +109,7 @@
{{.Demographic.Photo.PercentExplicit}}% {{.Demographic.Photo.PercentExplicit}}%
</progress> </progress>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow nonshy-percent-alignment">
{{.Demographic.Photo.PercentExplicit}}% {{.Demographic.Photo.PercentExplicit}}%
</div> </div>
</div> </div>
@ -172,7 +172,7 @@
{{.Percent}}% {{.Percent}}%
</progress> </progress>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow nonshy-percent-alignment">
{{.Percent}}% {{.Percent}}%
</div> </div>
</div> </div>
@ -196,7 +196,7 @@
{{.Percent}}% {{.Percent}}%
</progress> </progress>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow nonshy-percent-alignment">
{{.Percent}}% {{.Percent}}%
</div> </div>
</div> </div>
@ -221,7 +221,7 @@
{{.Percent}}% {{.Percent}}%
</progress> </progress>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow nonshy-percent-alignment">
{{.Percent}}% {{.Percent}}%
</div> </div>
</div> </div>
@ -230,4 +230,16 @@
</div> </div>
</div> </div>
<!-- Mobile CSS tweaks for this page -->
<style type="text/css">
.nonshy-percent-alignment {
min-width: 4.5rem;
}
@media screen and (min-width: 769px) {
.nonshy-percent-alignment {
text-align: right;
}
}
</style>
{{end}} {{end}}