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 } 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 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); 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}) }