┌─ nix/pkgs/ookstats/src/internal/generator/stats.go ────────────────────────┐
│ diff --git a/nix/pkgs/ookstats/src/internal/generator/stats.go b/nix/pkgs/ookstats │
│ /src/internal/generator/stats.go │
│ index a60d41b..d727a2a 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/generator/stats.go │
│ +++ b/nix/pkgs/ookstats/src/internal/generator/stats.go │
│ @@ -21,12 +21,28 @@ type StatsJSON struct { │
│ │
│ // StatsScope holds aggregated stats for a single scope (all-time or one season). │
│ type StatsScope struct { │
│ - TotalRuns int64 `json:"total_runs"` │
│ - TotalPlayers int64 `json:"total_players"` │
│ - NineOfNinePlayers int64 `json:"nine_of_nine_players"` │
│ - CompletionTiers CompletionTiers `json:"completion_tiers"` │
│ - SpecCounts map[string][]SpecCountEntry `json:"spec_counts"` │
│ - WeeklyActivity []WeeklyActivityEntry `json:"weekly_activity"` │
│ + TotalRuns int64 `json:"total_runs"` │
│ + TotalPlayers int64 `json:"total_players"` │
│ + NineOfNinePlayers int64 `json:"nine_of_nine_players"` │
│ + CompletionTiers CompletionTiers `json:"completion_tiers"` │
│ + SpecCounts map[string]SpecCountBucket `json:"spec_counts"` │
│ + WeeklyActivity []WeeklyActivityEntry `json:"weekly_activity"` │
│ +} │
│ + │
│ +// SpecCountBucket - entries plus the bucket's distinct-run denominator, so the │
│ +// chart can switch between "total slot count" and "% of runs containing the │
│ +// spec" without recomputing. `ByDungeon` is the same shape grouped per-dungeon, │
│ +// for the spec chart's dungeon filter and the dungeon-distribution chart. │
│ +type SpecCountBucket struct { │
│ + TotalRuns int64 `json:"total_runs"` │
│ + Entries []SpecCountEntry `json:"entries"` │
│ + ByDungeon map[int]DungeonSpecBucket `json:"by_dungeon"` │
│ +} │
│ + │
│ +// DungeonSpecBucket - sub-bucket for one dungeon within a SpecCountBucket. │
│ +type DungeonSpecBucket struct { │
│ + TotalRuns int64 `json:"total_runs"` │
│ + Entries []SpecCountEntry `json:"entries"` │
│ } │
│ │
│ // CompletionTiers groups the 9-of-9 medal-tier counts. │
│ @@ -48,11 +64,16 @@ type CompletionTier struct { │
│ } │
│ │
│ // SpecCountEntry - one row of the spec-distribution charts. │
│ +// `count` is the total number of run_member spec slots (a run with two of the │
│ +// same spec contributes 2). `runs_with_spec` is the count of distinct runs │
│ +// containing the spec (the same run with two of the spec contributes 1 - capped │
│ +// at the bucket's total_runs). │
│ type SpecCountEntry struct { │
│ - SpecID int `json:"spec_id"` │
│ - ClassName string `json:"class_name"` │
│ - SpecName string `json:"spec_name"` │
│ - Count int64 `json:"count"` │
│ + SpecID int `json:"spec_id"` │
│ + ClassName string `json:"class_name"` │
│ + SpecName string `json:"spec_name"` │
│ + Count int64 `json:"count"` │
│ + RunsWithSpec int64 `json:"runs_with_spec"` │
│ } │
│ │
│ // WeeklyActivityEntry - one bucket on the activity timeline (Monday-start week). │
│ @@ -386,8 +407,10 @@ func withPercentiles(t CompletionTier, totalPlayers, complete │
│ dPlayers, allTimePl │
│ // title_runs: same for title │
│ // top_50_runs: run_members where the run was top-50 in scope (globally team- │
│ filtered for │
│ // region=="global", regionally team-filtered for a specific reg │
│ ion) │
│ -func computeSpecCounts(db *sql.DB, seasonNum int, region string) (map[string][]Sp │
│ ecCountEntry, error) { │
│ - out := map[string][]SpecCountEntry{} │
│ +// Each bucket carries both `count` (slot occurrences, includes spec stacking) │
│ +// and `runs_with_spec` (distinct run count, capped at the bucket's total_runs). │
│ +func computeSpecCounts(db *sql.DB, seasonNum int, region string) (map[string]Spec │
│ CountBucket, error) { │
│ + out := map[string]SpecCountBucket{} │
│ │
│ allRuns, err := querySpecCountsForRuns(db, seasonNum, region) │
│ if err != nil { │
│ @@ -420,36 +443,157 @@ func computeSpecCounts(db *sql.DB, seasonNum int, region st │
│ ring) (map[string][]S │
│ } │
│ │
│ // querySpecCountsForRuns counts run_member spec_ids across all runs in scope. │
│ -func querySpecCountsForRuns(db *sql.DB, seasonNum int, region string) ([]SpecCoun │
│ tEntry, error) { │
│ +// Returns slot count + distinct-run count per spec, plus the bucket's total │
│ +// distinct run count, plus the same broken out per dungeon. │
│ +func querySpecCountsForRuns(db *sql.DB, seasonNum int, region string) (SpecCountB │
│ ucket, error) { │
│ regionJoin := "" │
│ if region != "" && region != "global" { │
│ regionJoin = "JOIN realms r ON r.id = cr.realm_id" │
│ } │
│ + sClause, sArgs := seasonClauseAndArg(seasonNum, "cr") │
│ + rClause, rArgs := regionClauseAndArg(region, "r") │
│ + args := append(sArgs, rArgs...) │
│ + │
│ + // One query grouped by (dungeon, spec) - we derive both the per-dungeon │
│ + // breakdown and the overall totals from the same result set, since each │
│ + // challenge_run has a single dungeon_id (no double-counting across dungeons). │
│ q := ` │
│ - SELECT rm.spec_id, COUNT(*) │
│ + SELECT cr.dungeon_id, rm.spec_id, │
│ + COUNT(*) AS slot_count, │
│ + COUNT(DISTINCT rm.run_id) AS run_count │
│ FROM run_members rm │
│ JOIN challenge_runs cr ON rm.run_id = cr.id │
│ %s │
│ WHERE rm.spec_id IS NOT NULL %s %s │
│ - GROUP BY rm.spec_id │
│ - ORDER BY COUNT(*) DESC │
│ + GROUP BY cr.dungeon_id, rm.spec_id │
│ ` │
│ + rows, err := db.Query(fmt.Sprintf(q, regionJoin, sClause, rClause), args...) │
│ + if err != nil { │
│ + return SpecCountBucket{}, err │
│ + } │
│ + defer rows.Close() │
│ + │
│ + // dungeonId -> specId -> {count, distinctRuns} │
│ + perDungeon := make(map[int]map[int]*specRunCount) │
│ + overall := make(map[int]*specRunCount) │
│ + for rows.Next() { │
│ + var dungeonID, specID int │
│ + var slot, runs int64 │
│ + if err := rows.Scan(&dungeonID, &specID, &slot, &runs); err != nil { │
│ + return SpecCountBucket{}, err │
│ + } │
│ + dm, ok := perDungeon[dungeonID] │
│ + if !ok { │
│ + dm = make(map[int]*specRunCount) │
│ + perDungeon[dungeonID] = dm │
│ + } │
│ + dm[specID] = &specRunCount{count: slot, distinctRuns: runs} │
│ + o := overall[specID] │
│ + if o == nil { │
│ + o = &specRunCount{} │
│ + overall[specID] = o │
│ + } │
│ + o.count += slot │
│ + // Each run lives in one dungeon, so distinct runs sum cleanly across dungeons. │
│ + o.distinctRuns += runs │
│ + } │
│ + if err := rows.Err(); err != nil { │
│ + return SpecCountBucket{}, err │
│ + } │
│ + │
│ + // Per-dungeon distinct run totals AND overall - one query, no scan loop tax. │
│ + perDungeonTotals, overallTotal, err := queryRunCountsForRuns(db, seasonNum, regi │
│ on) │
│ + if err != nil { │
│ + return SpecCountBucket{}, err │
│ + } │
│ + │
│ + bucket := SpecCountBucket{ │
│ + TotalRuns: overallTotal, │
│ + Entries: entriesFromCounts(overall), │
│ + ByDungeon: make(map[int]DungeonSpecBucket, len(perDungeonTotals)), │
│ + } │
│ + for dungeonID, total := range perDungeonTotals { │
│ + bucket.ByDungeon[dungeonID] = DungeonSpecBucket{ │
│ + TotalRuns: total, │
│ + Entries: entriesFromCounts(perDungeon[dungeonID]), │
│ + } │
│ + } │
│ + return bucket, nil │
│ +} │
│ + │
│ +// queryRunCountsForRuns returns distinct-run counts per dungeon (and the overall │
│ +// sum) within scope, used as the denominator for the spec-distribution chart's │
│ +// "Runs" metric and for the dungeon-distribution chart. │
│ +func queryRunCountsForRuns(db *sql.DB, seasonNum int, region string) (map[int]int │
│ 64, int64, error) { │
│ + regionJoin := "" │
│ + if region != "" && region != "global" { │
│ + regionJoin = "JOIN realms r ON r.id = cr.realm_id" │
│ + } │
│ sClause, sArgs := seasonClauseAndArg(seasonNum, "cr") │
│ rClause, rArgs := regionClauseAndArg(region, "r") │
│ args := append(sArgs, rArgs...) │
│ - rows, err := db.Query(fmt.Sprintf(q, regionJoin, sClause, rClause), args...) │
│ + q := fmt.Sprintf(` │
│ + SELECT cr.dungeon_id, COUNT(DISTINCT cr.id) │
│ + FROM challenge_runs cr │
│ + %s │
│ + WHERE 1=1 %s %s │
│ + GROUP BY cr.dungeon_id │
│ + `, regionJoin, sClause, rClause) │
│ + rows, err := db.Query(q, args...) │
│ if err != nil { │
│ - return nil, err │
│ + return nil, 0, err │
│ } │
│ defer rows.Close() │
│ - return scanSpecCounts(rows) │
│ + perDungeon := make(map[int]int64) │
│ + var total int64 │
│ + for rows.Next() { │
│ + var dungeonID int │
│ + var count int64 │
│ + if err := rows.Scan(&dungeonID, &count); err != nil { │
│ + return nil, 0, err │
│ + } │
│ + perDungeon[dungeonID] = count │
│ + total += count │
│ + } │
│ + return perDungeon, total, rows.Err() │
│ +} │
│ + │
│ +// specRunCount aggregates slot count + distinct-run count for a single spec. │
│ +type specRunCount struct { │
│ + count, distinctRuns int64 │
│ +} │
│ + │
│ +// entriesFromCounts converts a (specId -> {count, distinctRuns}) map into the │
│ +// sorted SpecCountEntry slice used by the chart, attaching class/spec metadata. │
│ +// Sorted descending by count for stable order; the chart re-orders by canonical │
│ +// class+spec layout but tooltips/iteration work nicer on a deterministic input. │
│ +func entriesFromCounts(m map[int]*specRunCount) []SpecCountEntry { │
│ + if m == nil { │
│ + return []SpecCountEntry{} │
│ + } │
│ + out := make([]SpecCountEntry, 0, len(m)) │
│ + for specID, c := range m { │
│ + entry := SpecCountEntry{SpecID: specID, Count: c.count, RunsWithSpec: c.distinc │
│ tRuns} │
│ + if cls, spec, ok := wow.GetClassAndSpec(specID); ok { │
│ + entry.ClassName = cls │
│ + entry.SpecName = spec │
│ + } │
│ + out = append(out, entry) │
│ + } │
│ + for i := 1; i < len(out); i++ { │
│ + for j := i; j > 0 && out[j].Count > out[j-1].Count; j-- { │
│ + out[j], out[j-1] = out[j-1], out[j] │
│ + } │
│ + } │
│ + return out │
│ } │
│ │
│ // querySpecCountsForTier counts run_member spec_ids across runs that beat a per- │
│ dungeon threshold. │
│ // We can't easily express the per-dungeon threshold in a single SQL query, so in │
│ stead we load │
│ // (run_id, dungeon_id, duration) rows in scope, decide qualification in Go, then │
│ count specs by │
│ -// the qualifying run_id set. │
│ -func querySpecCountsForTier(db *sql.DB, seasonNum int, region string, thresholdF │
│ func(dungeonThresholds) int64) ([]SpecCountEntry, error) { │
│ +// the qualifying run_id set. We track both slot occurrences and distinct-run-wit │
│ h-spec counts, │
│ +// and a per-dungeon breakdown for the spec-distribution chart's dungeon filter. │
│ +func querySpecCountsForTier(db *sql.DB, seasonNum int, region string, thresholdF │
│ func(dungeonThresholds) int64) (SpecCountBucket, error) { │
│ regionJoin := "" │
│ if region != "" && region != "global" { │
│ regionJoin = "JOIN realms r ON r.id = cr.realm_id" │
│ @@ -464,68 +608,113 @@ func querySpecCountsForTier(db *sql.DB, seasonNum int, regi │
│ on string, thresholdF │
│ WHERE 1=1 %s %s │
│ `, regionJoin, sClause, rClause), args...) │
│ if err != nil { │
│ - return nil, err │
│ + return SpecCountBucket{}, err │
│ } │
│ - qualifying := make(map[int64]struct{}) │
│ + // runId -> dungeonId for runs that beat the threshold. Per-dungeon counts │
│ + // fall out of the same map. │
│ + qualifyingDungeon := make(map[int64]int) │
│ + dungeonRunCounts := make(map[int]int64) │
│ for rows.Next() { │
│ var id int64 │
│ var dungeonID int │
│ var duration int64 │
│ if err := rows.Scan(&id, &dungeonID, &duration); err != nil { │
│ rows.Close() │
│ - return nil, err │
│ + return SpecCountBucket{}, err │
│ } │
│ threshold, ok := dungeonTimerThresholds[dungeonID] │
│ if !ok { │
│ continue │
│ } │
│ if beatsTier(duration, thresholdF(threshold)) { │
│ - qualifying[id] = struct{}{} │
│ + qualifyingDungeon[id] = dungeonID │
│ + dungeonRunCounts[dungeonID]++ │
│ } │
│ } │
│ rows.Close() │
│ - if len(qualifying) == 0 { │
│ - return []SpecCountEntry{}, nil │
│ + if len(qualifyingDungeon) == 0 { │
│ + return SpecCountBucket{TotalRuns: 0, Entries: []SpecCountEntry{}, ByDungeon: ma │
│ p[int]DungeonSpecBucket{}}, nil │
│ } │
│ │
│ - // Count spec_ids in run_members for qualifying runs │
│ - specCounts := make(map[int]int64) │
│ + // For qualifying runs, count slot occurrences AND distinct runs per spec, │
│ + // both overall and per-dungeon. Per-dungeon distinct-run counting reuses │
│ + // the qualifying map (one run -> one dungeon, no double-count). │
│ + overall := make(map[int]*specAgg) │
│ + perDungeon := make(map[int]map[int]*specAgg) │
│ memberRows, err := db.Query(` │
│ SELECT run_id, spec_id │
│ FROM run_members │
│ WHERE spec_id IS NOT NULL │
│ `) │
│ if err != nil { │
│ - return nil, err │
│ + return SpecCountBucket{}, err │
│ } │
│ defer memberRows.Close() │
│ for memberRows.Next() { │
│ var runID int64 │
│ var specID int │
│ if err := memberRows.Scan(&runID, &specID); err != nil { │
│ - return nil, err │
│ + return SpecCountBucket{}, err │
│ } │
│ - if _, ok := qualifying[runID]; !ok { │
│ + dungeonID, ok := qualifyingDungeon[runID] │
│ + if !ok { │
│ continue │
│ } │
│ - specCounts[specID]++ │
│ + o := overall[specID] │
│ + if o == nil { │
│ + o = &specAgg{runs: make(map[int64]struct{})} │
│ + overall[specID] = o │
│ + } │
│ + o.count++ │
│ + o.runs[runID] = struct{}{} │
│ + │
│ + dm, ok := perDungeon[dungeonID] │
│ + if !ok { │
│ + dm = make(map[int]*specAgg) │
│ + perDungeon[dungeonID] = dm │
│ + } │
│ + da := dm[specID] │
│ + if da == nil { │
│ + da = &specAgg{runs: make(map[int64]struct{})} │
│ + dm[specID] = da │
│ + } │
│ + da.count++ │
│ + da.runs[runID] = struct{}{} │
│ + } │
│ + │
│ + bucket := SpecCountBucket{ │
│ + TotalRuns: int64(len(qualifyingDungeon)), │
│ + Entries: specAggsToSlice(overall), │
│ + ByDungeon: make(map[int]DungeonSpecBucket, len(perDungeon)), │
│ + } │
│ + for dungeonID, total := range dungeonRunCounts { │
│ + bucket.ByDungeon[dungeonID] = DungeonSpecBucket{ │
│ + TotalRuns: total, │
│ + Entries: specAggsToSlice(perDungeon[dungeonID]), │
│ + } │
│ } │
│ - return specCountsToSlice(specCounts), nil │
│ + return bucket, nil │
│ } │
│ │
│ // querySpecCountsForTop50 counts spec_ids in top-50 runs. │
│ // region "global" ? top 50 globally (filtered); region "us"/"eu"/etc ? top 50 wi │
│ thin that region. │
│ -func querySpecCountsForTop50(db *sql.DB, seasonNum int, region string) ([]SpecCou │
│ ntEntry, error) { │
│ +// Per-dungeon breakdown is also returned (each dungeon's top 50 contributes 50 r │
│ uns to its bucket). │
│ +func querySpecCountsForTop50(db *sql.DB, seasonNum int, region string) (SpecCount │
│ Bucket, error) { │
│ rankingType := "global" │
│ rankingScope := "filtered" │
│ if region != "" && region != "global" { │
│ rankingType = "regional" │
│ rankingScope = region + "_filtered" │
│ } │
│ + // Group by (dungeon, spec) so we get both per-dungeon and overall totals │
│ + // from a single result set. cr.dungeon_id comes from joining challenge_runs. │
│ q := ` │
│ - SELECT rm.spec_id, COUNT(*) │
│ + SELECT cr.dungeon_id, rm.spec_id, │
│ + COUNT(*) AS slot_count, │
│ + COUNT(DISTINCT rm.run_id) AS run_count │
│ FROM run_members rm │
│ JOIN run_rankings rr ON rm.run_id = rr.run_id │
│ + JOIN challenge_runs cr ON cr.id = rm.run_id │
│ WHERE rm.spec_id IS NOT NULL │
│ AND rr.ranking_type = ? │
│ AND rr.ranking_scope = ? │
│ @@ -536,39 +725,100 @@ func querySpecCountsForTop50(db *sql.DB, seasonNum int, reg │
│ ion string) ([]SpecCo │
│ q += " AND rr.season_id = ?" │
│ args = append(args, seasonNum) │
│ } │
│ - q += " GROUP BY rm.spec_id ORDER BY COUNT(*) DESC" │
│ + q += " GROUP BY cr.dungeon_id, rm.spec_id" │
│ │
│ rows, err := db.Query(q, args...) │
│ if err != nil { │
│ - return nil, err │
│ + return SpecCountBucket{}, err │
│ } │
│ defer rows.Close() │
│ - return scanSpecCounts(rows) │
│ -} │
│ │
│ -func scanSpecCounts(rows *sql.Rows) ([]SpecCountEntry, error) { │
│ - var out []SpecCountEntry │
│ + perDungeon := make(map[int]map[int]*specRunCount) │
│ + overall := make(map[int]*specRunCount) │
│ for rows.Next() { │
│ - var specID int │
│ + var dungeonID, specID int │
│ + var slot, runs int64 │
│ + if err := rows.Scan(&dungeonID, &specID, &slot, &runs); err != nil { │
│ + return SpecCountBucket{}, err │
│ + } │
│ + dm, ok := perDungeon[dungeonID] │
│ + if !ok { │
│ + dm = make(map[int]*specRunCount) │
│ + perDungeon[dungeonID] = dm │
│ + } │
│ + dm[specID] = &specRunCount{count: slot, distinctRuns: runs} │
│ + o := overall[specID] │
│ + if o == nil { │
│ + o = &specRunCount{} │
│ + overall[specID] = o │
│ + } │
│ + o.count += slot │
│ + o.distinctRuns += runs │
│ + } │
│ + if err := rows.Err(); err != nil { │
│ + return SpecCountBucket{}, err │
│ + } │
│ + │
│ + // Per-dungeon distinct top-50 run counts (and total). │
│ + totalsQ := ` │
│ + SELECT cr.dungeon_id, COUNT(DISTINCT rr.run_id) │
│ + FROM run_rankings rr │
│ + JOIN challenge_runs cr ON cr.id = rr.run_id │
│ + WHERE rr.ranking_type = ? │
│ + AND rr.ranking_scope = ? │
│ + AND rr.ranking <= 50 │
│ + ` │
│ + totalsArgs := []any{rankingType, rankingScope} │
│ + if seasonNum != 0 { │
│ + totalsQ += " AND rr.season_id = ?" │
│ + totalsArgs = append(totalsArgs, seasonNum) │
│ + } │
│ + totalsQ += " GROUP BY cr.dungeon_id" │
│ + totalRows, err := db.Query(totalsQ, totalsArgs...) │
│ + if err != nil { │
│ + return SpecCountBucket{}, err │
│ + } │
│ + defer totalRows.Close() │
│ + dungeonTotals := make(map[int]int64) │
│ + var grandTotal int64 │
│ + for totalRows.Next() { │
│ + var dungeonID int │
│ var count int64 │
│ - if err := rows.Scan(&specID, &count); err != nil { │
│ - return nil, err │
│ + if err := totalRows.Scan(&dungeonID, &count); err != nil { │
│ + return SpecCountBucket{}, err │
│ } │
│ - entry := SpecCountEntry{SpecID: specID, Count: count} │
│ - if cls, spec, ok := wow.GetClassAndSpec(specID); ok { │
│ - entry.ClassName = cls │
│ - entry.SpecName = spec │
│ + dungeonTotals[dungeonID] = count │
│ + grandTotal += count │
│ + } │
│ + │
│ + bucket := SpecCountBucket{ │
│ + TotalRuns: grandTotal, │
│ + Entries: entriesFromCounts(overall), │
│ + ByDungeon: make(map[int]DungeonSpecBucket, len(dungeonTotals)), │
│ + } │
│ + for dungeonID, total := range dungeonTotals { │
│ + bucket.ByDungeon[dungeonID] = DungeonSpecBucket{ │
│ + TotalRuns: total, │
│ + Entries: entriesFromCounts(perDungeon[dungeonID]), │
│ } │
│ - out = append(out, entry) │
│ } │
│ - return out, rows.Err() │
│ + return bucket, nil │
│ } │
│ │
│ -// specCountsToSlice converts a map to a sorted SpecCountEntry slice (desc by cou │
│ nt). │
│ -func specCountsToSlice(m map[int]int64) []SpecCountEntry { │
│ - out := make([]SpecCountEntry, 0, len(m)) │
│ - for specID, count := range m { │
│ - entry := SpecCountEntry{SpecID: specID, Count: count} │
│ +// specAgg holds in-memory aggregation state when we can't count via SQL - │
│ +// see querySpecCountsForTier. │
│ +type specAgg struct { │
│ + count int64 │
│ + runs map[int64]struct{} │
│ +} │
│ + │
│ +// specAggsToSlice converts a Go-side aggregation (slot count + run-id set per │
│ +// spec) into a sorted slice - used by querySpecCountsForTier where the │
│ +// per-dungeon threshold makes a SQL-only aggregation awkward. │
│ +func specAggsToSlice(aggs map[int]*specAgg) []SpecCountEntry { │
│ + out := make([]SpecCountEntry, 0, len(aggs)) │
│ + for specID, a := range aggs { │
│ + entry := SpecCountEntry{SpecID: specID, Count: a.count, RunsWithSpec: int64(len │
│ (a.runs))} │
│ if cls, spec, ok := wow.GetClassAndSpec(specID); ok { │
│ entry.ClassName = cls │
│ entry.SpecName = spec │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/internal/generator/stats.go ───┐
│ diff --git a/nix/pkgs/ookstats/src/internal/ │
│ generator/stats.go b/nix/pkgs/ookstats/src/i │
│ nternal/generator/stats.go │
│ index a60d41b..d727a2a 100644 │
│ --- a/nix/pkgs/ookstats/src/internal/generat │
│ or/stats.go │
│ +++ b/nix/pkgs/ookstats/src/internal/generat │
│ or/stats.go │
│ @@ -21,12 +21,28 @@ type StatsJSON struct { │
│ │
│ // StatsScope holds aggregated stats for a │
│ single scope (all-time or one season). │
│ type StatsScope struct { │
│ - TotalRuns int64 │
│ `json:"total_runs"` │
│ - TotalPlayers int64 │
│ `json:"total_players"` │
│ - NineOfNinePlayers int64 │
│ `json:"nine_of_nine_players"` │
│ - CompletionTiers CompletionTiers │
│ `json:"completion_tiers"` │
│ - SpecCounts map[string][]SpecCountEn │
│ try `json:"spec_counts"` │
│ - WeeklyActivity []WeeklyActivityEntry │
│ `json:"weekly_activity"` │
│ + TotalRuns int64 │
│ `json:"total_runs"` │
│ + TotalPlayers int64 │
│ `json:"total_players"` │
│ + NineOfNinePlayers int64 │
│ `json:"nine_of_nine_players"` │
│ + CompletionTiers CompletionTiers │
│ `json:"completion_tiers"` │
│ + SpecCounts map[string]SpecCountBuck │
│ et `json:"spec_counts"` │
│ + WeeklyActivity []WeeklyActivityEntry │
│ `json:"weekly_activity"` │
│ +} │
│ + │
│ +// SpecCountBucket - entries plus the bucke │
│ t's distinct-run denominator, so the │
│ +// chart can switch between "total slot cou │
│ nt" and "% of runs containing the │
│ +// spec" without recomputing. `ByDungeon` i │
│ s the same shape grouped per-dungeon, │
│ +// for the spec chart's dungeon filter and │
│ the dungeon-distribution chart. │
│ +type SpecCountBucket struct { │
│ + TotalRuns int64 `jso │
│ n:"total_runs"` │
│ + Entries []SpecCountEntry `jso │
│ n:"entries"` │
│ + ByDungeon map[int]DungeonSpecBucket `jso │
│ n:"by_dungeon"` │
│ +} │
│ + │
│ +// DungeonSpecBucket - sub-bucket for one d │
│ ungeon within a SpecCountBucket. │
│ +type DungeonSpecBucket struct { │
│ + TotalRuns int64 `json:"total_ru │
│ ns"` │
│ + Entries []SpecCountEntry `json:"entries" │
│ ` │
│ } │
│ │
│ // CompletionTiers groups the 9-of-9 medal- │
│ tier counts. │
│ @@ -48,11 +64,16 @@ type CompletionTier stru │
│ ct { │
│ } │
│ │
│ // SpecCountEntry - one row of the spec-dis │
│ tribution charts. │
│ +// `count` is the total number of run_membe │
│ r spec slots (a run with two of the │
│ +// same spec contributes 2). `runs_with_spe │
│ c` is the count of distinct runs │
│ +// containing the spec (the same run with t │
│ wo of the spec contributes 1 - capped │
│ +// at the bucket's total_runs). │
│ type SpecCountEntry struct { │
│ - SpecID int `json:"spec_id"` │
│ - ClassName string `json:"class_name"` │
│ - SpecName string `json:"spec_name"` │
│ - Count int64 `json:"count"` │
│ + SpecID int `json:"spec_id"` │
│ + ClassName string `json:"class_name"` │
│ + SpecName string `json:"spec_name"` │
│ + Count int64 `json:"count"` │
│ + RunsWithSpec int64 `json:"runs_with_spec" │
│ ` │
│ } │
│ │
│ // WeeklyActivityEntry - one bucket on the │
│ activity timeline (Monday-start week). │
│ @@ -386,8 +407,10 @@ func withPercentiles(t │
│ CompletionTier, totalPlayers, completedPlaye │
│ rs, allTimePl │
│ // title_runs: same for title │
│ // top_50_runs: run_members where the r │
│ un was top-50 in scope (globally team-filter │
│ ed for │
│ // region=="global", regio │
│ nally team-filtered for a specific region) │
│ -func computeSpecCounts(db *sql.DB, seasonNu │
│ m int, region string) (map[string][]SpecCoun │
│ tEntry, error) { │
│ - out := map[string][]SpecCountEntry{} │
│ +// Each bucket carries both `count` (slot o │
│ ccurrences, includes spec stacking) │
│ +// and `runs_with_spec` (distinct run count │
│ , capped at the bucket's total_runs). │
│ +func computeSpecCounts(db *sql.DB, seasonNu │
│ m int, region string) (map[string]SpecCountB │
│ ucket, error) { │
│ + out := map[string]SpecCountBucket{} │
│ │
│ allRuns, err := querySpecCountsForRuns(db, │
│ seasonNum, region) │
│ if err != nil { │
│ @@ -420,36 +443,157 @@ func computeSpecCount │
│ s(db *sql.DB, seasonNum int, region string) │
│ (map[string][]S │
│ } │
│ │
│ // querySpecCountsForRuns counts run_member │
│ spec_ids across all runs in scope. │
│ -func querySpecCountsForRuns(db *sql.DB, sea │
│ sonNum int, region string) ([]SpecCountEntry │
│ , error) { │
│ +// Returns slot count + distinct-run count │
│ per spec, plus the bucket's total │
│ +// distinct run count, plus the same broken │
│ out per dungeon. │
│ +func querySpecCountsForRuns(db *sql.DB, sea │
│ sonNum int, region string) (SpecCountBucket, │
│ error) { │
│ regionJoin := "" │
│ if region != "" && region != "global" { │
│ regionJoin = "JOIN realms r ON r.id = cr. │
│ realm_id" │
│ } │
│ + sClause, sArgs := seasonClauseAndArg(seaso │
│ nNum, "cr") │
│ + rClause, rArgs := regionClauseAndArg(regio │
│ n, "r") │
│ + args := append(sArgs, rArgs...) │
│ + │
│ + // One query grouped by (dungeon, spec) - │
│ we derive both the per-dungeon │
│ + // breakdown and the overall totals from t │
│ he same result set, since each │
│ + // challenge_run has a single dungeon_id ( │
│ no double-counting across dungeons). │
│ q := ` │
│ - SELECT rm.spec_id, COUNT(*) │
│ + SELECT cr.dungeon_id, rm.spec_id, │
│ + COUNT(*) AS slot_count, │
│ + COUNT(DISTINCT rm.run_id) AS run_c │
│ ount │
│ FROM run_members rm │
│ JOIN challenge_runs cr ON rm.run_id = cr. │
│ id │
│ %s │
│ WHERE rm.spec_id IS NOT NULL %s %s │
│ - GROUP BY rm.spec_id │
│ - ORDER BY COUNT(*) DESC │
│ + GROUP BY cr.dungeon_id, rm.spec_id │
│ ` │
│ + rows, err := db.Query(fmt.Sprintf(q, regio │
│ nJoin, sClause, rClause), args...) │
│ + if err != nil { │
│ + return SpecCountBucket{}, err │
│ + } │
│ + defer rows.Close() │
│ + │
│ + // dungeonId -> specId -> {count, distinct │
│ Runs} │
│ + perDungeon := make(map[int]map[int]*specRu │
│ nCount) │
│ + overall := make(map[int]*specRunCount) │
│ + for rows.Next() { │
│ + var dungeonID, specID int │
│ + var slot, runs int64 │
│ + if err := rows.Scan(&dungeonID, &specID, │
│ &slot, &runs); err != nil { │
│ + return SpecCountBucket{}, err │
│ + } │
│ + dm, ok := perDungeon[dungeonID] │
│ + if !ok { │
│ + dm = make(map[int]*specRunCount) │
│ + perDungeon[dungeonID] = dm │
│ + } │
│ + dm[specID] = &specRunCount{count: slot, d │
│ istinctRuns: runs} │
│ + o := overall[specID] │
│ + if o == nil { │
│ + o = &specRunCount{} │
│ + overall[specID] = o │
│ + } │
│ + o.count += slot │
│ + // Each run lives in one dungeon, so dist │
│ inct runs sum cleanly across dungeons. │
│ + o.distinctRuns += runs │
│ + } │
│ + if err := rows.Err(); err != nil { │
│ + return SpecCountBucket{}, err │
│ + } │
│ + │
│ + // Per-dungeon distinct run totals AND ove │
│ rall - one query, no scan loop tax. │
│ + perDungeonTotals, overallTotal, err := que │
│ ryRunCountsForRuns(db, seasonNum, region) │
│ + if err != nil { │
│ + return SpecCountBucket{}, err │
│ + } │
│ + │
│ + bucket := SpecCountBucket{ │
│ + TotalRuns: overallTotal, │
│ + Entries: entriesFromCounts(overall), │
│ + ByDungeon: make(map[int]DungeonSpecBucket │
│ , len(perDungeonTotals)), │
│ + } │
│ + for dungeonID, total := range perDungeonTo │
│ tals { │
│ + bucket.ByDungeon[dungeonID] = DungeonSpec │
│ Bucket{ │
│ + TotalRuns: total, │
│ + Entries: entriesFromCounts(perDungeon[ │
│ dungeonID]), │
│ + } │
│ + } │
│ + return bucket, nil │
│ +} │
│ + │
│ +// queryRunCountsForRuns returns distinct-r │
│ un counts per dungeon (and the overall │
│ +// sum) within scope, used as the denominat │
│ or for the spec-distribution chart's │
│ +// "Runs" metric and for the dungeon-distri │
│ bution chart. │
│ +func queryRunCountsForRuns(db *sql.DB, seas │
│ onNum int, region string) (map[int]int64, in │
│ t64, error) { │
│ + regionJoin := "" │
│ + if region != "" && region != "global" { │
│ + regionJoin = "JOIN realms r ON r.id = cr. │
│ realm_id" │
│ + } │
│ sClause, sArgs := seasonClauseAndArg(seaso │
│ nNum, "cr") │
│ rClause, rArgs := regionClauseAndArg(regio │
│ n, "r") │
│ args := append(sArgs, rArgs...) │
│ - rows, err := db.Query(fmt.Sprintf(q, regio │
│ nJoin, sClause, rClause), args...) │
│ + q := fmt.Sprintf(` │
│ + SELECT cr.dungeon_id, COUNT(DISTINCT cr.i │
│ d) │
│ + FROM challenge_runs cr │
│ + %s │
│ + WHERE 1=1 %s %s │
│ + GROUP BY cr.dungeon_id │
│ + `, regionJoin, sClause, rClause) │
│ + rows, err := db.Query(q, args...) │
│ if err != nil { │
│ - return nil, err │
│ + return nil, 0, err │
│ } │
│ defer rows.Close() │
│ - return scanSpecCounts(rows) │
│ + perDungeon := make(map[int]int64) │
│ + var total int64 │
│ + for rows.Next() { │
│ + var dungeonID int │
│ + var count int64 │
│ + if err := rows.Scan(&dungeonID, &count); │
│ err != nil { │
│ + return nil, 0, err │
│ + } │
│ + perDungeon[dungeonID] = count │
│ + total += count │
│ + } │
│ + return perDungeon, total, rows.Err() │
│ +} │
│ + │
│ +// specRunCount aggregates slot count + dis │
│ tinct-run count for a single spec. │
│ +type specRunCount struct { │
│ + count, distinctRuns int64 │
│ +} │
│ + │
│ +// entriesFromCounts converts a (specId -> │
│ {count, distinctRuns}) map into the │
│ +// sorted SpecCountEntry slice used by the │
│ chart, attaching class/spec metadata. │
│ +// Sorted descending by count for stable or │
│ der; the chart re-orders by canonical │
│ +// class+spec layout but tooltips/iteration │
│ work nicer on a deterministic input. │
│ +func entriesFromCounts(m map[int]*specRunCo │
│ unt) []SpecCountEntry { │
│ + if m == nil { │
│ + return []SpecCountEntry{} │
│ + } │
│ + out := make([]SpecCountEntry, 0, len(m)) │
│ + for specID, c := range m { │
│ + entry := SpecCountEntry{SpecID: specID, C │
│ ount: c.count, RunsWithSpec: c.distinctRuns} │
│ + if cls, spec, ok := wow.GetClassAndSpec(s │
│ pecID); ok { │
│ + entry.ClassName = cls │
│ + entry.SpecName = spec │
│ + } │
│ + out = append(out, entry) │
│ + } │
│ + for i := 1; i < len(out); i++ { │
│ + for j := i; j > 0 && out[j].Count > out[j │
│ -1].Count; j-- { │
│ + out[j], out[j-1] = out[j-1], out[j] │
│ + } │
│ + } │
│ + return out │
│ } │
│ │
│ // querySpecCountsForTier counts run_member │
│ spec_ids across runs that beat a per-dungeo │
│ n threshold. │
│ // We can't easily express the per-dungeon │
│ threshold in a single SQL query, so instead │
│ we load │
│ // (run_id, dungeon_id, duration) rows in s │
│ cope, decide qualification in Go, then count │
│ specs by │
│ -// the qualifying run_id set. │
│ -func querySpecCountsForTier(db *sql.DB, sea │
│ sonNum int, region string, thresholdF func(d │
│ ungeonThresholds) int64) ([]SpecCountEntry, │
│ error) { │
│ +// the qualifying run_id set. We track both │
│ slot occurrences and distinct-run-with-spec │
│ counts, │
│ +// and a per-dungeon breakdown for the spec │
│ -distribution chart's dungeon filter. │
│ +func querySpecCountsForTier(db *sql.DB, sea │
│ sonNum int, region string, thresholdF func(d │
│ ungeonThresholds) int64) (SpecCountBucket, e │
│ rror) { │
│ regionJoin := "" │
│ if region != "" && region != "global" { │
│ regionJoin = "JOIN realms r ON r.id = cr. │
│ realm_id" │
│ @@ -464,68 +608,113 @@ func querySpecCountsF │
│ orTier(db *sql.DB, seasonNum int, region str │
│ ing, thresholdF │
│ WHERE 1=1 %s %s │
│ `, regionJoin, sClause, rClause), args...) │
│ if err != nil { │
│ - return nil, err │
│ + return SpecCountBucket{}, err │
│ } │
│ - qualifying := make(map[int64]struct{}) │
│ + // runId -> dungeonId for runs that beat t │
│ he threshold. Per-dungeon counts │
│ + // fall out of the same map. │
│ + qualifyingDungeon := make(map[int64]int) │
│ + dungeonRunCounts := make(map[int]int64) │
│ for rows.Next() { │
│ var id int64 │
│ var dungeonID int │
│ var duration int64 │
│ if err := rows.Scan(&id, &dungeonID, &dur │
│ ation); err != nil { │
│ rows.Close() │
│ - return nil, err │
│ + return SpecCountBucket{}, err │
│ } │
│ threshold, ok := dungeonTimerThresholds[d │
│ ungeonID] │
│ if !ok { │
│ continue │
│ } │
│ if beatsTier(duration, thresholdF(thresho │
│ ld)) { │
│ - qualifying[id] = struct{}{} │
│ + qualifyingDungeon[id] = dungeonID │
│ + dungeonRunCounts[dungeonID]++ │
│ } │
│ } │
│ rows.Close() │
│ - if len(qualifying) == 0 { │
│ - return []SpecCountEntry{}, nil │
│ + if len(qualifyingDungeon) == 0 { │
│ + return SpecCountBucket{TotalRuns: 0, Entr │
│ ies: []SpecCountEntry{}, ByDungeon: map[int] │
│ DungeonSpecBucket{}}, nil │
│ } │
│ │
│ - // Count spec_ids in run_members for quali │
│ fying runs │
│ - specCounts := make(map[int]int64) │
│ + // For qualifying runs, count slot occurre │
│ nces AND distinct runs per spec, │
│ + // both overall and per-dungeon. Per-dunge │
│ on distinct-run counting reuses │
│ + // the qualifying map (one run -> one dung │
│ eon, no double-count). │
│ + overall := make(map[int]*specAgg) │
│ + perDungeon := make(map[int]map[int]*specAg │
│ g) │
│ memberRows, err := db.Query(` │
│ SELECT run_id, spec_id │
│ FROM run_members │
│ WHERE spec_id IS NOT NULL │
│ `) │
│ if err != nil { │
│ - return nil, err │
│ + return SpecCountBucket{}, err │
│ } │
│ defer memberRows.Close() │
│ for memberRows.Next() { │
│ var runID int64 │
│ var specID int │
│ if err := memberRows.Scan(&runID, &specID │
│ ); err != nil { │
│ - return nil, err │
│ + return SpecCountBucket{}, err │
│ } │
│ - if _, ok := qualifying[runID]; !ok { │
│ + dungeonID, ok := qualifyingDungeon[runID] │
│ + if !ok { │
│ continue │
│ } │
│ - specCounts[specID]++ │
│ + o := overall[specID] │
│ + if o == nil { │
│ + o = &specAgg{runs: make(map[int64]struct │
│ {})} │
│ + overall[specID] = o │
│ + } │
│ + o.count++ │
│ + o.runs[runID] = struct{}{} │
│ + │
│ + dm, ok := perDungeon[dungeonID] │
│ + if !ok { │
│ + dm = make(map[int]*specAgg) │
│ + perDungeon[dungeonID] = dm │
│ + } │
│ + da := dm[specID] │
│ + if da == nil { │
│ + da = &specAgg{runs: make(map[int64]struc │
│ t{})} │
│ + dm[specID] = da │
│ + } │
│ + da.count++ │
│ + da.runs[runID] = struct{}{} │
│ + } │
│ + │
│ + bucket := SpecCountBucket{ │
│ + TotalRuns: int64(len(qualifyingDungeon)), │
│ + Entries: specAggsToSlice(overall), │
│ + ByDungeon: make(map[int]DungeonSpecBucket │
│ , len(perDungeon)), │
│ + } │
│ + for dungeonID, total := range dungeonRunCo │
│ unts { │
│ + bucket.ByDungeon[dungeonID] = DungeonSpec │
│ Bucket{ │
│ + TotalRuns: total, │
│ + Entries: specAggsToSlice(perDungeon[du │
│ ngeonID]), │
│ + } │
│ } │
│ - return specCountsToSlice(specCounts), nil │
│ + return bucket, nil │
│ } │
│ │
│ // querySpecCountsForTop50 counts spec_ids │
│ in top-50 runs. │
│ // region "global" ? top 50 globally (filte │
│ red); region "us"/"eu"/etc ? top 50 within t │
│ hat region. │
│ -func querySpecCountsForTop50(db *sql.DB, se │
│ asonNum int, region string) ([]SpecCountEntr │
│ y, error) { │
│ +// Per-dungeon breakdown is also returned ( │
│ each dungeon's top 50 contributes 50 runs to │
│ its bucket). │
│ +func querySpecCountsForTop50(db *sql.DB, se │
│ asonNum int, region string) (SpecCountBucket │
│ , error) { │
│ rankingType := "global" │
│ rankingScope := "filtered" │
│ if region != "" && region != "global" { │
│ rankingType = "regional" │
│ rankingScope = region + "_filtered" │
│ } │
│ + // Group by (dungeon, spec) so we get both │
│ per-dungeon and overall totals │
│ + // from a single result set. cr.dungeon_id │
│ comes from joining challenge_runs. │
│ q := ` │
│ - SELECT rm.spec_id, COUNT(*) │
│ + SELECT cr.dungeon_id, rm.spec_id, │
│ + COUNT(*) AS slot_count, │
│ + COUNT(DISTINCT rm.run_id) AS run_c │
│ ount │
│ FROM run_members rm │
│ JOIN run_rankings rr ON rm.run_id = rr.ru │
│ n_id │
│ + JOIN challenge_runs cr ON cr.id = rm.run_ │
│ id │
│ WHERE rm.spec_id IS NOT NULL │
│ AND rr.ranking_type = ? │
│ AND rr.ranking_scope = ? │
│ @@ -536,39 +725,100 @@ func querySpecCountsF │
│ orTop50(db *sql.DB, seasonNum int, region st │
│ ring) ([]SpecCo │
│ q += " AND rr.season_id = ?" │
│ args = append(args, seasonNum) │
│ } │
│ - q += " GROUP BY rm.spec_id ORDER BY COUNT( │
│ *) DESC" │
│ + q += " GROUP BY cr.dungeon_id, rm.spec_id" │
│ │
│ rows, err := db.Query(q, args...) │
│ if err != nil { │
│ - return nil, err │
│ + return SpecCountBucket{}, err │
│ } │
│ defer rows.Close() │
│ - return scanSpecCounts(rows) │
│ -} │
│ │
│ -func scanSpecCounts(rows *sql.Rows) ([]Spec │
│ CountEntry, error) { │
│ - var out []SpecCountEntry │
│ + perDungeon := make(map[int]map[int]*specRu │
│ nCount) │
│ + overall := make(map[int]*specRunCount) │
│ for rows.Next() { │
│ - var specID int │
│ + var dungeonID, specID int │
│ + var slot, runs int64 │
│ + if err := rows.Scan(&dungeonID, &specID, │
│ &slot, &runs); err != nil { │
│ + return SpecCountBucket{}, err │
│ + } │
│ + dm, ok := perDungeon[dungeonID] │
│ + if !ok { │
│ + dm = make(map[int]*specRunCount) │
│ + perDungeon[dungeonID] = dm │
│ + } │
│ + dm[specID] = &specRunCount{count: slot, d │
│ istinctRuns: runs} │
│ + o := overall[specID] │
│ + if o == nil { │
│ + o = &specRunCount{} │
│ + overall[specID] = o │
│ + } │
│ + o.count += slot │
│ + o.distinctRuns += runs │
│ + } │
│ + if err := rows.Err(); err != nil { │
│ + return SpecCountBucket{}, err │
│ + } │
│ + │
│ + // Per-dungeon distinct top-50 run counts │
│ (and total). │
│ + totalsQ := ` │
│ + SELECT cr.dungeon_id, COUNT(DISTINCT rr.r │
│ un_id) │
│ + FROM run_rankings rr │
│ + JOIN challenge_runs cr ON cr.id = rr.run_ │
│ id │
│ + WHERE rr.ranking_type = ? │
│ + AND rr.ranking_scope = ? │
│ + AND rr.ranking <= 50 │
│ + ` │
│ + totalsArgs := []any{rankingType, rankingSc │
│ ope} │
│ + if seasonNum != 0 { │
│ + totalsQ += " AND rr.season_id = ?" │
│ + totalsArgs = append(totalsArgs, seasonNum │
│ ) │
│ + } │
│ + totalsQ += " GROUP BY cr.dungeon_id" │
│ + totalRows, err := db.Query(totalsQ, totals │
│ Args...) │
│ + if err != nil { │
│ + return SpecCountBucket{}, err │
│ + } │
│ + defer totalRows.Close() │
│ + dungeonTotals := make(map[int]int64) │
│ + var grandTotal int64 │
│ + for totalRows.Next() { │
│ + var dungeonID int │
│ var count int64 │
│ - if err := rows.Scan(&specID, &count); err │
│ != nil { │
│ - return nil, err │
│ + if err := totalRows.Scan(&dungeonID, &cou │
│ nt); err != nil { │
│ + return SpecCountBucket{}, err │
│ } │
│ - entry := SpecCountEntry{SpecID: specID, C │
│ ount: count} │
│ - if cls, spec, ok := wow.GetClassAndSpec(s │
│ pecID); ok { │
│ - entry.ClassName = cls │
│ - entry.SpecName = spec │
│ + dungeonTotals[dungeonID] = count │
│ + grandTotal += count │
│ + } │
│ + │
│ + bucket := SpecCountBucket{ │
│ + TotalRuns: grandTotal, │
│ + Entries: entriesFromCounts(overall), │
│ + ByDungeon: make(map[int]DungeonSpecBucket │
│ , len(dungeonTotals)), │
│ + } │
│ + for dungeonID, total := range dungeonTotal │
│ s { │
│ + bucket.ByDungeon[dungeonID] = DungeonSpec │
│ Bucket{ │
│ + TotalRuns: total, │
│ + Entries: entriesFromCounts(perDungeon[ │
│ dungeonID]), │
│ } │
│ - out = append(out, entry) │
│ } │
│ - return out, rows.Err() │
│ + return bucket, nil │
│ } │
│ │
│ -// specCountsToSlice converts a map to a so │
│ rted SpecCountEntry slice (desc by count). │
│ -func specCountsToSlice(m map[int]int64) []S │
│ pecCountEntry { │
│ - out := make([]SpecCountEntry, 0, len(m)) │
│ - for specID, count := range m { │
│ - entry := SpecCountEntry{SpecID: specID, C │
│ ount: count} │
│ +// specAgg holds in-memory aggregation stat │
│ e when we can't count via SQL - │
│ +// see querySpecCountsForTier. │
│ +type specAgg struct { │
│ + count int64 │
│ + runs map[int64]struct{} │
│ +} │
│ + │
│ +// specAggsToSlice converts a Go-side aggre │
│ gation (slot count + run-id set per │
│ +// spec) into a sorted slice - used by quer │
│ ySpecCountsForTier where the │
│ +// per-dungeon threshold makes a SQL-only a │
│ ggregation awkward. │
│ +func specAggsToSlice(aggs map[int]*specAgg) │
│ []SpecCountEntry { │
│ + out := make([]SpecCountEntry, 0, len(aggs) │
│ ) │
│ + for specID, a := range aggs { │
│ + entry := SpecCountEntry{SpecID: specID, C │
│ ount: a.count, RunsWithSpec: int64(len(a.run │
│ s))} │
│ if cls, spec, ok := wow.GetClassAndSpec(s │
│ pecID); ok { │
│ entry.ClassName = cls │
│ entry.SpecName = spec │
└──────────────────────────────────────────────┘
┌─ ...src/components/Stats/SpecDistributionChart/SpecDistributionChart.ts ───┐
│ diff --git a/web/src/components/Stats/SpecDistributionChart/SpecDistributionChart. │
│ ts b/web/src/components/Stats/SpecDistributionChart/SpecDistributionChart.ts │
│ index 4181544..04f1c2e 100644 │
│ --- a/web/src/components/Stats/SpecDistributionChart/SpecDistributionChart.ts │
│ +++ b/web/src/components/Stats/SpecDistributionChart/SpecDistributionChart.ts │
│ @@ -7,9 +7,27 @@ interface SpecEntry { │
│ spec_id: number; │
│ class_name: string; │
│ spec_name: string; │
│ + // Total spec slot occurrences (a run with two of the spec contributes 2). │
│ count: number; │
│ + // Distinct runs containing this spec (a run with two of the spec contributes 1 │
│ ). │
│ + runs_with_spec: number; │
│ } │
│ │
│ +interface DungeonSpecBucket { │
│ + total_runs: number; │
│ + entries: SpecEntry[]; │
│ +} │
│ + │
│ +interface SpecBucket { │
│ + total_runs: number; │
│ + entries: SpecEntry[]; │
│ + by_dungeon: Record<string, DungeonSpecBucket>; │
│ +} │
│ + │
│ +// "picks" = total spec slot occurrences (a stacked spec contributes once per slo │
│ t). │
│ +// "runs" = distinct runs containing the spec. │
│ +type Metric = "picks" | "runs"; │
│ + │
│ const SVG_NS = "http://www.w3.org/2000/svg"; │
│ const XLINK_NS = "http://www.w3.org/1999/xlink"; │
│ │
│ @@ -71,7 +89,7 @@ function classSlug(name: string): string { │
│ return name.toLowerCase().replace(/\s+/g, "-"); │
│ } │
│ │
│ -function parseBuckets(el: HTMLElement): Record<string, SpecEntry[]> { │
│ +function parseBuckets(el: HTMLElement): Record<string, SpecBucket> { │
│ const raw = el.getAttribute("data-buckets"); │
│ if (!raw) return {}; │
│ try { │
│ @@ -93,6 +111,22 @@ function activeRoleKey(container: HTMLElement): string { │
│ return select?.value ?? "all"; │
│ } │
│ │
│ +function activeMetric(container: HTMLElement): Metric { │
│ + const select = container.querySelector<HTMLSelectElement>("[data-metric-select] │
│ "); │
│ + return select?.value === "runs" ? "runs" : "picks"; │
│ +} │
│ + │
│ +function activeDungeonKey(container: HTMLElement): string { │
│ + const select = container.querySelector<HTMLSelectElement>("[data-dungeon-select │
│ ]"); │
│ + return select?.value ?? "all"; │
│ +} │
│ + │
│ +// Pull the metric-relevant value out of an entry - bar height + tooltip both │
│ +// route through this so swapping the metric is one place. │
│ +function entryValue(e: SpecEntry, metric: Metric): number { │
│ + return metric === "runs" ? e.runs_with_spec : e.count; │
│ +} │
│ + │
│ function renderChart(container: HTMLElement) { │
│ const canvas = container.querySelector(".spec-distribution-chart__canvas") as H │
│ TMLElement | null; │
│ if (!canvas) return; │
│ @@ -100,19 +134,36 @@ function renderChart(container: HTMLElement) { │
│ const buckets = parseBuckets(container); │
│ const bucketKey = activeBucketKey(container); │
│ const roleKey = activeRoleKey(container); │
│ - const entries = buckets[bucketKey] ?? []; │
│ - │
│ - // build lookup spec_id -> count for the active bucket │
│ - const countBySpec = new Map<number, number>(); │
│ + const metric = activeMetric(container); │
│ + const dungeonKey = activeDungeonKey(container); │
│ + const fullBucket = buckets[bucketKey] ?? { │
│ + total_runs: 0, │
│ + entries: [], │
│ + by_dungeon: {}, │
│ + }; │
│ + // When a dungeon is picked, slice to that dungeon's sub-bucket so total_runs, │
│ + // entries, and the share denominator all come from the same scope. │
│ + const bucket: { total_runs: number; entries: SpecEntry[] } = │
│ + dungeonKey === "all" │
│ + ? { total_runs: fullBucket.total_runs, entries: fullBucket.entries } │
│ + : (fullBucket.by_dungeon?.[dungeonKey] ?? { total_runs: 0, entries: [] }); │
│ + const entries = bucket.entries; │
│ + │
│ + // build lookup spec_id -> entry for the active bucket │
│ + const entryBySpec = new Map<number, SpecEntry>(); │
│ for (const e of entries) { │
│ - countBySpec.set(e.spec_id, e.count); │
│ + entryBySpec.set(e.spec_id, e); │
│ } │
│ + const valueOf = (specId: number): number => { │
│ + const e = entryBySpec.get(specId); │
│ + return e ? entryValue(e, metric) : 0; │
│ + }; │
│ │
│ // Apply both filters: role + zero-hide. Specs with no count in this bucket │
│ // disappear entirely (no empty slot left behind). │
│ const visible = SPECS.filter((s) => { │
│ if (roleKey !== "all" && s.role !== roleKey) return false; │
│ - return (countBySpec.get(s.specId) ?? 0) > 0; │
│ + return valueOf(s.specId) > 0; │
│ }); │
│ │
│ // Recompute group breaks on the visible set (last spec of each class group). │
│ @@ -166,8 +217,14 @@ function renderChart(container: HTMLElement) { │
│ const height = 280; │
│ const innerH = height - padding.top - padding.bottom; │
│ │
│ - const maxCount = Math.max(1, ...visibleWithGap.map((s) => countBySpec.get(s.spe │
│ cId) ?? 0)); │
│ - const totalRuns = visibleWithGap.reduce((acc, s) => acc + (countBySpec.get(s.sp │
│ ecId) ?? 0), 0); │
│ + const maxCount = Math.max(1, ...visibleWithGap.map((s) => valueOf(s.specId))); │
│ + // Denominator for the tooltip share %. │
│ + // - picks mode: fraction of all spec slots in the visible filter │
│ + // - runs mode: fraction of distinct runs in the bucket containing the spec │
│ + const shareDenominator = │
│ + metric === "runs" │
│ + ? bucket.total_runs │
│ + : visibleWithGap.reduce((acc, s) => acc + valueOf(s.specId), 0); │
│ │
│ // Build SVG │
│ const svg = document.createElementNS(SVG_NS, "svg"); │
│ @@ -224,16 +281,16 @@ function renderChart(container: HTMLElement) { │
│ svg.appendChild(highlight); │
│ │
│ // Pre-compute per-bar metadata for the overlay's nearest-column lookup. │
│ - const cols: Array<{ spec: Spec; count: number; x: number; y: number; cx: number │
│ }> = []; │
│ + const cols: Array<{ spec: Spec; value: number; x: number; y: number; cx: number │
│ }> = []; │
│ │
│ // Bars + icons │
│ for (let i = 0; i < visibleWithGap.length; i++) { │
│ const s = visibleWithGap[i]; │
│ - const count = countBySpec.get(s.specId) ?? 0; │
│ - const barH = (count / maxCount) * innerH; │
│ + const value = valueOf(s.specId); │
│ + const barH = (value / maxCount) * innerH; │
│ const x = slotX[i]; │
│ const y = padding.top + innerH - barH; │
│ - cols.push({ spec: s, count, x, y, cx: x + barW / 2 }); │
│ + cols.push({ spec: s, value, x, y, cx: x + barW / 2 }); │
│ │
│ // Bar │
│ const rect = document.createElementNS(SVG_NS, "rect"); │
│ @@ -296,12 +353,18 @@ function renderChart(container: HTMLElement) { │
│ highlight.setAttribute("x", String(c.x)); │
│ highlight.setAttribute("opacity", "1"); │
│ │
│ - const sharePct = totalRuns > 0 ? (c.count / totalRuns) * 100 : 0; │
│ + const sharePct = │
│ + shareDenominator > 0 ? (c.value / shareDenominator) * 100 : 0; │
│ + const valueText = c.value.toLocaleString("en-US"); │
│ + const mainLine = │
│ + metric === "runs" │
│ + ? `${valueText} of ${bucket.total_runs.toLocaleString("en-US")} runs` │
│ + : `${valueText} picks`; │
│ tooltip.innerHTML = │
│ `<div class="spec-distribution-chart__tooltip-title">` + │
│ `<span class="text-${classSlug(c.spec.className)}">${c.spec.specName} ${c.s │
│ pec.className}</span>` + │
│ `</div>` + │
│ - `<div class="spec-distribution-chart__tooltip-value">${c.count.toLocaleStri │
│ ng("en-US")} runs` + │
│ + `<div class="spec-distribution-chart__tooltip-value">${mainLine}` + │
│ ` <span class="spec-distribution-chart__tooltip-share">(${sharePct.toFixed( │
│ 1)}%)</span></div>`; │
│ tooltip.style.opacity = "1"; │
│ │
│ @@ -351,6 +414,12 @@ function wireControls(container: HTMLElement) { │
│ │
│ const roleSelect = container.querySelector<HTMLSelectElement>("[data-role-selec │
│ t]"); │
│ roleSelect?.addEventListener("change", () => renderChart(container)); │
│ + │
│ + const metricSelect = container.querySelector<HTMLSelectElement>("[data-metric-s │
│ elect]"); │
│ + metricSelect?.addEventListener("change", () => renderChart(container)); │
│ + │
│ + const dungeonSelect = container.querySelector<HTMLSelectElement>("[data-dungeon │
│ -select]"); │
│ + dungeonSelect?.addEventListener("change", () => renderChart(container)); │
│ } │
│ │
│ export function initSpecDistributionCharts() { │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...hart/SpecDistributionChart.ts ───┐
│ diff --git a/web/src/components/Stats/SpecDi │
│ stributionChart/SpecDistributionChart.ts b/w │
│ eb/src/components/Stats/SpecDistributionChar │
│ t/SpecDistributionChart.ts │
│ index 4181544..04f1c2e 100644 │
│ --- a/web/src/components/Stats/SpecDistribut │
│ ionChart/SpecDistributionChart.ts │
│ +++ b/web/src/components/Stats/SpecDistribut │
│ ionChart/SpecDistributionChart.ts │
│ @@ -7,9 +7,27 @@ interface SpecEntry { │
│ spec_id: number; │
│ class_name: string; │
│ spec_name: string; │
│ + // Total spec slot occurrences (a run wit │
│ h two of the spec contributes 2). │
│ count: number; │
│ + // Distinct runs containing this spec (a │
│ run with two of the spec contributes 1). │
│ + runs_with_spec: number; │
│ } │
│ │
│ +interface DungeonSpecBucket { │
│ + total_runs: number; │
│ + entries: SpecEntry[]; │
│ +} │
│ + │
│ +interface SpecBucket { │
│ + total_runs: number; │
│ + entries: SpecEntry[]; │
│ + by_dungeon: Record<string, DungeonSpecBuc │
│ ket>; │
│ +} │
│ + │
│ +// "picks" = total spec slot occurrences (a │
│ stacked spec contributes once per slot). │
│ +// "runs" = distinct runs containing the s │
│ pec. │
│ +type Metric = "picks" | "runs"; │
│ + │
│ const SVG_NS = "http://www.w3.org/2000/svg" │
│ ; │
│ const XLINK_NS = "http://www.w3.org/1999/xl │
│ ink"; │
│ │
│ @@ -71,7 +89,7 @@ function classSlug(name: s │
│ tring): string { │
│ return name.toLowerCase().replace(/\s+/g, │
│ "-"); │
│ } │
│ │
│ -function parseBuckets(el: HTMLElement): Rec │
│ ord<string, SpecEntry[]> { │
│ +function parseBuckets(el: HTMLElement): Rec │
│ ord<string, SpecBucket> { │
│ const raw = el.getAttribute("data-buckets │
│ "); │
│ if (!raw) return {}; │
│ try { │
│ @@ -93,6 +111,22 @@ function activeRoleKey(c │
│ ontainer: HTMLElement): string { │
│ return select?.value ?? "all"; │
│ } │
│ │
│ +function activeMetric(container: HTMLElemen │
│ t): Metric { │
│ + const select = container.querySelector<HT │
│ MLSelectElement>("[data-metric-select]"); │
│ + return select?.value === "runs" ? "runs" │
│ : "picks"; │
│ +} │
│ + │
│ +function activeDungeonKey(container: HTMLEl │
│ ement): string { │
│ + const select = container.querySelector<HT │
│ MLSelectElement>("[data-dungeon-select]"); │
│ + return select?.value ?? "all"; │
│ +} │
│ + │
│ +// Pull the metric-relevant value out of an │
│ entry - bar height + tooltip both │
│ +// route through this so swapping the metri │
│ c is one place. │
│ +function entryValue(e: SpecEntry, metric: M │
│ etric): number { │
│ + return metric === "runs" ? e.runs_with_sp │
│ ec : e.count; │
│ +} │
│ + │
│ function renderChart(container: HTMLElement │
│ ) { │
│ const canvas = container.querySelector(". │
│ spec-distribution-chart__canvas") as HTMLEle │
│ ment | null; │
│ if (!canvas) return; │
│ @@ -100,19 +134,36 @@ function renderChart(c │
│ ontainer: HTMLElement) { │
│ const buckets = parseBuckets(container); │
│ const bucketKey = activeBucketKey(contain │
│ er); │
│ const roleKey = activeRoleKey(container); │
│ - const entries = buckets[bucketKey] ?? []; │
│ - │
│ - // build lookup spec_id -> count for the │
│ active bucket │
│ - const countBySpec = new Map<number, numbe │
│ r>(); │
│ + const metric = activeMetric(container); │
│ + const dungeonKey = activeDungeonKey(conta │
│ iner); │
│ + const fullBucket = buckets[bucketKey] ?? │
│ { │
│ + total_runs: 0, │
│ + entries: [], │
│ + by_dungeon: {}, │
│ + }; │
│ + // When a dungeon is picked, slice to tha │
│ t dungeon's sub-bucket so total_runs, │
│ + // entries, and the share denominator all │
│ come from the same scope. │
│ + const bucket: { total_runs: number; entri │
│ es: SpecEntry[] } = │
│ + dungeonKey === "all" │
│ + ? { total_runs: fullBucket.total_runs │
│ , entries: fullBucket.entries } │
│ + : (fullBucket.by_dungeon?.[dungeonKey │
│ ] ?? { total_runs: 0, entries: [] }); │
│ + const entries = bucket.entries; │
│ + │
│ + // build lookup spec_id -> entry for the │
│ active bucket │
│ + const entryBySpec = new Map<number, SpecE │
│ ntry>(); │
│ for (const e of entries) { │
│ - countBySpec.set(e.spec_id, e.count); │
│ + entryBySpec.set(e.spec_id, e); │
│ } │
│ + const valueOf = (specId: number): number │
│ => { │
│ + const e = entryBySpec.get(specId); │
│ + return e ? entryValue(e, metric) : 0; │
│ + }; │
│ │
│ // Apply both filters: role + zero-hide. │
│ Specs with no count in this bucket │
│ // disappear entirely (no empty slot left │
│ behind). │
│ const visible = SPECS.filter((s) => { │
│ if (roleKey !== "all" && s.role !== rol │
│ eKey) return false; │
│ - return (countBySpec.get(s.specId) ?? 0) │
│ > 0; │
│ + return valueOf(s.specId) > 0; │
│ }); │
│ │
│ // Recompute group breaks on the visible │
│ set (last spec of each class group). │
│ @@ -166,8 +217,14 @@ function renderChart(co │
│ ntainer: HTMLElement) { │
│ const height = 280; │
│ const innerH = height - padding.top - pad │
│ ding.bottom; │
│ │
│ - const maxCount = Math.max(1, ...visibleWi │
│ thGap.map((s) => countBySpec.get(s.specId) ? │
│ ? 0)); │
│ - const totalRuns = visibleWithGap.reduce(( │
│ acc, s) => acc + (countBySpec.get(s.specId) │
│ ?? 0), 0); │
│ + const maxCount = Math.max(1, ...visibleWi │
│ thGap.map((s) => valueOf(s.specId))); │
│ + // Denominator for the tooltip share %. │
│ + // - picks mode: fraction of all spec s │
│ lots in the visible filter │
│ + // - runs mode: fraction of distinct r │
│ uns in the bucket containing the spec │
│ + const shareDenominator = │
│ + metric === "runs" │
│ + ? bucket.total_runs │
│ + : visibleWithGap.reduce((acc, s) => a │
│ cc + valueOf(s.specId), 0); │
│ │
│ // Build SVG │
│ const svg = document.createElementNS(SVG_ │
│ NS, "svg"); │
│ @@ -224,16 +281,16 @@ function renderChart(c │
│ ontainer: HTMLElement) { │
│ svg.appendChild(highlight); │
│ │
│ // Pre-compute per-bar metadata for the o │
│ verlay's nearest-column lookup. │
│ - const cols: Array<{ spec: Spec; count: nu │
│ mber; x: number; y: number; cx: number }> = │
│ []; │
│ + const cols: Array<{ spec: Spec; value: nu │
│ mber; x: number; y: number; cx: number }> = │
│ []; │
│ │
│ // Bars + icons │
│ for (let i = 0; i < visibleWithGap.length │
│ ; i++) { │
│ const s = visibleWithGap[i]; │
│ - const count = countBySpec.get(s.specId) │
│ ?? 0; │
│ - const barH = (count / maxCount) * inner │
│ H; │
│ + const value = valueOf(s.specId); │
│ + const barH = (value / maxCount) * inner │
│ H; │
│ const x = slotX[i]; │
│ const y = padding.top + innerH - barH; │
│ - cols.push({ spec: s, count, x, y, cx: x │
│ + barW / 2 }); │
│ + cols.push({ spec: s, value, x, y, cx: x │
│ + barW / 2 }); │
│ │
│ // Bar │
│ const rect = document.createElementNS(S │
│ VG_NS, "rect"); │
│ @@ -296,12 +353,18 @@ function renderChart(c │
│ ontainer: HTMLElement) { │
│ highlight.setAttribute("x", String(c.x) │
│ ); │
│ highlight.setAttribute("opacity", "1"); │
│ │
│ - const sharePct = totalRuns > 0 ? (c.cou │
│ nt / totalRuns) * 100 : 0; │
│ + const sharePct = │
│ + shareDenominator > 0 ? (c.value / sha │
│ reDenominator) * 100 : 0; │
│ + const valueText = c.value.toLocaleStrin │
│ g("en-US"); │
│ + const mainLine = │
│ + metric === "runs" │
│ + ? `${valueText} of ${bucket.total_r │
│ uns.toLocaleString("en-US")} runs` │
│ + : `${valueText} picks`; │
│ tooltip.innerHTML = │
│ `<div class="spec-distribution-chart_ │
│ _tooltip-title">` + │
│ `<span class="text-${classSlug(c.spec │
│ .className)}">${c.spec.specName} ${c.spec.cl │
│ assName}</span>` + │
│ `</div>` + │
│ - `<div class="spec-distribution-chart_ │
│ _tooltip-value">${c.count.toLocaleString("en │
│ -US")} runs` + │
│ + `<div class="spec-distribution-chart_ │
│ _tooltip-value">${mainLine}` + │
│ ` <span class="spec-distribution-char │
│ t__tooltip-share">(${sharePct.toFixed(1)}%)< │
│ /span></div>`; │
│ tooltip.style.opacity = "1"; │
│ │
│ @@ -351,6 +414,12 @@ function wireControls(c │
│ ontainer: HTMLElement) { │
│ │
│ const roleSelect = container.querySelecto │
│ r<HTMLSelectElement>("[data-role-select]"); │
│ roleSelect?.addEventListener("change", () │
│ => renderChart(container)); │
│ + │
│ + const metricSelect = container.querySelec │
│ tor<HTMLSelectElement>("[data-metric-select] │
│ "); │
│ + metricSelect?.addEventListener("change", │
│ () => renderChart(container)); │
│ + │
│ + const dungeonSelect = container.querySele │
│ ctor<HTMLSelectElement>("[data-dungeon-selec │
│ t]"); │
│ + dungeonSelect?.addEventListener("change", │
│ () => renderChart(container)); │
│ } │
│ │
│ export function initSpecDistributionCharts( │
│ ) { │
└──────────────────────────────────────────────┘