The app is about operating something — playing a track and setting a level on it — but every screen looked like a form. One metaphor now does three jobs. - Reviewing: a vertical fader beside the text, so the two things you do at once stop being a screen apart. Native range input, so keyboard, focus and form submission are unchanged; on mobile it lies down and the ticks reverse - The reveal: everyone's scores as a row of channels. The silhouette of that row is the spread, which the stats page can only tell you as a number - Profiles: given versus received as two faders, the one comparison that says something about a person The player is now a transport: play/pause, a range input for seeking so arrow keys come free, and a stereo level meter driven by a real AnalyserNode. It is progressive enhancement — the page ships native audio controls and the script takes over, so no JS means the browser's own player. The meter is dark until audio actually plays and stops when it does; reduced motion skips it entirely. Also: hidden scores are hatched rather than blank, the nav carries the queue count, "Seuraava jonossa" keeps the loop going after a review, leaderboards gained level bars and a range bar where divisive is the point, durations read 3:54, both lists can get back to the start, and the admin invite table lists unused codes instead of silently truncating at 50. Slogan restored from the original app, three decades on.
163 lines
5.0 KiB
Go
163 lines
5.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
)
|
|
|
|
// A song needs this many reviews to qualify for any ranking: with ten members it means a third of
|
|
// the club has weighed in, which is a real threshold rather than a formality.
|
|
const minReviews = 3
|
|
|
|
type songStat struct {
|
|
ID int64
|
|
Title string
|
|
Artist string
|
|
Value float64
|
|
ReviewCount int
|
|
Min int // the spread, which is what "divisive" actually means
|
|
Max int
|
|
}
|
|
|
|
// Percentages for the bars, so the templates hold no arithmetic.
|
|
func (s songStat) Pct() float64 { return s.Value }
|
|
func (s songStat) MinPct() float64 { return float64(s.Min) }
|
|
func (s songStat) SpanPct() float64 {
|
|
if s.Max <= s.Min {
|
|
return 1
|
|
}
|
|
return float64(s.Max - s.Min)
|
|
}
|
|
|
|
type userStat struct {
|
|
ID int64
|
|
Name string
|
|
Value float64
|
|
Count int
|
|
Avatar *string
|
|
}
|
|
|
|
func (u *userStat) Initials() string { m := member{Name: u.Name}; return m.Initials() }
|
|
|
|
type stats struct {
|
|
MinReviews int
|
|
|
|
TopSongs []songStat
|
|
BottomSongs []songStat
|
|
MostDivisive []songStat
|
|
MostUnified []songStat
|
|
MostReviewed []songStat
|
|
|
|
Harshest []userStat
|
|
MostGenerous []userStat
|
|
MostActive []userStat
|
|
MostProlific []userStat
|
|
}
|
|
|
|
// Every leaderboard is ordered and limited in SQL, and every one carries a deterministic tie-break:
|
|
// ties are common in a ten-person club, and without one Postgres may return a different ten each
|
|
// time, so the page visibly reshuffles between reloads for no reason.
|
|
func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string) ([]songStat, error) {
|
|
rows, err := a.pool.Query(ctx, `
|
|
select s.id, s.title, s.artist, `+valueExpr+`::float as value, count(r.id)::int as reviews,
|
|
min(r.score)::int, max(r.score)::int
|
|
from songs s join reviews r on r.song_id = s.id
|
|
group by s.id
|
|
having count(r.id) >= $1
|
|
order by value `+direction+`, reviews desc, s.id asc
|
|
limit 10`, minReviews)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []songStat
|
|
for rows.Next() {
|
|
var s songStat
|
|
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Value, &s.ReviewCount,
|
|
&s.Min, &s.Max); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Reviewer boards need a minimum too, or one enthusiastic 100 makes someone the most generous
|
|
// member in the club forever.
|
|
func (a *app) reviewerLeaderboard(ctx context.Context, valueExpr, direction string) ([]userStat, error) {
|
|
rows, err := a.pool.Query(ctx, `
|
|
select u.id, u.name, u.avatar, `+valueExpr+`::float as value, count(r.id)::int as n
|
|
from users u join reviews r on r.reviewer_id = u.id
|
|
group by u.id
|
|
having count(r.id) >= $1
|
|
order by value `+direction+`, n desc, u.id asc
|
|
limit 10`, minReviews)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []userStat
|
|
for rows.Next() {
|
|
var u userStat
|
|
if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (a *app) mostProlific(ctx context.Context) ([]userStat, error) {
|
|
rows, err := a.pool.Query(ctx, `
|
|
select u.id, u.name, u.avatar, count(s.id)::float, count(s.id)::int
|
|
from users u join songs s on s.submitted_by = u.id
|
|
group by u.id
|
|
order by count(s.id) desc, u.id asc
|
|
limit 10`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []userStat
|
|
for rows.Next() {
|
|
var u userStat
|
|
if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// The reveal rule does not apply here: leaderboards are always public. That is the whole point of
|
|
// /stats being a page you walk to deliberately.
|
|
func (a *app) statsPage(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
s := stats{MinReviews: minReviews}
|
|
|
|
var err error
|
|
for _, load := range []func() error{
|
|
func() (err error) { s.TopSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "desc"); return },
|
|
func() (err error) { s.BottomSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "asc"); return },
|
|
func() (err error) {
|
|
s.MostDivisive, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "desc")
|
|
return
|
|
},
|
|
func() (err error) { s.MostUnified, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc"); return },
|
|
func() (err error) { s.MostReviewed, err = a.songLeaderboard(ctx, "count(r.id)", "desc"); return },
|
|
func() (err error) { s.Harshest, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "asc"); return },
|
|
func() (err error) { s.MostGenerous, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "desc"); return },
|
|
func() (err error) { s.MostActive, err = a.reviewerLeaderboard(ctx, "count(r.id)", "desc"); return },
|
|
func() (err error) { s.MostProlific, err = a.mostProlific(ctx); return },
|
|
} {
|
|
if err = load(); err != nil {
|
|
slog.Error("stats", "ctx", "songs", "error", err)
|
|
http.Error(w, "virhe", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
a.render(w, r, http.StatusOK, "stats.html", page{Title: "Tilastot", Data: s})
|
|
}
|