Files
Levyraati26_go/stats.go
T
Esa Kataja 0f15ae0bfc Release 2026.08.02-1
SQLite replaces Postgres, and two fixes from using the thing.

- The database is a file under ./storage instead of a second container. Ten
  members never needed a database server, and the driver is pure Go, so the
  build stays CGO_ENABLED=0 and the dependency count is unchanged. One bind
  mount is now the whole backup: no pgdata, no healthcheck-gated depends_on,
  no startup retry loop. Timestamps are UTC text, idle_ttl is seconds, the
  divisive/unified boards carry their own stddev, and foreign keys are on by
  pragma. Tests get a database file each and run without any setup
- Invites are copied, not clicked. An invite is something to send, and the
  anchor opened the join form in the admin's own browser
- Feedback asks for more than faults: the footer reads "Ongelmia? Ideoita?
  Palautetta?" and the page behind it invites ideas rather than only bugs
- Kuuntele YouTubessa opens in a new tab, so a half-typed review survives it
2026-08-02 20:58:52 +03:00

165 lines
5.3 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
}
// SQLite has no stddev aggregate. This is the population formula written out; max() absorbs the
// tiny negative that floating-point cancellation produces when every score is identical, which
// would otherwise make sqrt() return null and fail the scan.
const stddevPop = `sqrt(max(0.0, avg(r.score * r.score) - avg(r.score) * avg(r.score)))`
// 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 the database 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.db.QueryContext(ctx, `
select s.id, s.title, s.artist, cast(`+valueExpr+` as real) as value,
count(r.id) as reviews, min(r.score), max(r.score)
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.db.QueryContext(ctx, `
select u.id, u.name, u.avatar, cast(`+valueExpr+` as real) as value, count(r.id) 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.db.QueryContext(ctx, `
select u.id, u.name, u.avatar, cast(count(s.id) as real), count(s.id)
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, stddevPop, "desc"); return },
func() (err error) { s.MostUnified, err = a.songLeaderboard(ctx, stddevPop, "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})
}