Step 6. The surfaces around the review loop. - Nine leaderboards, ordered and limited in SQL, each with a deterministic tie-break so a tied board doesn't reshuffle between reloads. Min 3 reviews to qualify, for reviewer boards too - Profiles show counts and history-wide averages and the member's songs, never a list of their reviews — per-song opinion stays gated - Avatars: 5MB in, 256px JPEG out, ffmpeg's re-encode being the validation. No upload still means initials, and avatars are public - Changing your own password requires the current one and drops your other sessions - Palaute: free text plus the page you were on, carried in a footer link, and the user agent from the header. Reporters see their own; the admin resolves them with a timestamp rather than a status enum - Admin gained the song list with delete, the reports page, and an open-report count on the dashboard Two theme fixes the screenshots caught: leaderboard ranks need a CSS counter because display:grid suppresses list markers, and count-based boards were printing 3.0 where they mean 3.
149 lines
4.5 KiB
Go
149 lines
4.5 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
|
|
}
|
|
|
|
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})
|
|
}
|