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
This commit is contained in:
@@ -55,13 +55,18 @@ type stats struct {
|
||||
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 Postgres may return a different ten each
|
||||
// time, so the page visibly reshuffles between reloads for no reason.
|
||||
// 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.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
|
||||
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
|
||||
@@ -86,8 +91,8 @@ func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string)
|
||||
// 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
|
||||
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
|
||||
@@ -109,8 +114,8 @@ func (a *app) reviewerLeaderboard(ctx context.Context, valueExpr, direction stri
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
@@ -140,11 +145,8 @@ func (a *app) statsPage(w http.ResponseWriter, r *http.Request) {
|
||||
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.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 },
|
||||
|
||||
Reference in New Issue
Block a user