OOKNET                             [ /  search the index  ]  
──────────────────────────────────────────────────────────────────────────────────────
══════════════════════════════════════════════════════════════════════════════════════
OOKNET   [ /  search  ]  
────────────────────────────────────────────────
════════════════════════════════════════════════
 
HASH      6af1b00a3e4d
DATE      2026-05-03
SUBJECT   ookstats: spec dist picks/runs metric + per-dungeon breakdown
FILES     5 CHANGED
HASH      6af1b00a3e4d
DATE      2026-05-03
SUBJECT   ookstats: spec dist picks/runs metric
          + per-dungeon breakdown
FILES     5 CHANGED
 

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

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
 

diff --git a/web/src/components/Stats/SpecDistributionChart/SpecDistributionChart.
astro b/web/src/components/Stats/SpecDistributionChart/SpecDistributionChart.astro
index 3e24df1..b7f609c 100644
--- a/web/src/components/Stats/SpecDistributionChart/SpecDistributionChart.astro
+++ b/web/src/components/Stats/SpecDistributionChart/SpecDistributionChart.astro
@@ -1,15 +1,40 @@
 ---
 import "./SpecDistributionChart.scss";
-import type { StatsSpecCountEntry } from "../../../lib/types";
+import type { StatsSpecCountBucket } from "../../../lib/types";
+import { DUNGEON_SLUG_TO_ID } from "../../../lib/dungeon-thresholds";
 
 interface Props {
-  buckets: Record<string, StatsSpecCountEntry[]>;
+  buckets: Record<string, StatsSpecCountBucket>;
   // unique scope id so multiple charts on a page can each find their own data is
land
   scope: string;
 }
 
 const { buckets, scope } = Astro.props;
 
+// Dungeon dropdown options. "all" is the default (overall data); each dungeon
+// id maps to a per-dungeon slice via bucket.by_dungeon[id].
+const DUNGEON_OPTIONS: Array<{ key: string; label: string }> = [
+  { key: "all", label: "All Dungeons" },
+  { key: "2", label: "Temple of the Jade Serpent" },
+  { key: "56", label: "Stormstout Brewery" },
+  { key: "57", label: "Gate of the Setting Sun" },
+  { key: "58", label: "Shado-Pan Monastery" },
+  { key: "59", label: "Siege of Niuzao Temple" },
+  { key: "60", label: "Mogu'shan Palace" },
+  { key: "76", label: "Scholomance" },
+  { key: "77", label: "Scarlet Halls" },
+  { key: "78", label: "Scarlet Monastery" },
+];
+
+// Light sanity check at build time - every option (besides "all") matches the
+// canonical slug?id map. Catches a typo in either side early.
+for (const o of DUNGEON_OPTIONS) {
+  if (o.key === "all") continue;
+  if (!Object.values(DUNGEON_SLUG_TO_ID).includes(parseInt(o.key, 10))) {
+    throw new Error(`Unknown dungeon id ${o.key} in SpecDistributionChart`);
+  }
+}
+
 const BUCKET_TABS: Array<{ key: string; label: string }> = [
   { key: "all_runs", label: "All Runs" },
   { key: "gold_runs", label: "Gold" },
@@ -25,6 +50,14 @@ const ROLE_TABS: Array<{ key: string; label: string }> = [
   { key: "dps", label: "DPS" },
 ];
 
+// "Picks" counts every spec slot (includes spec stacking, can exceed run count).
+// "Runs" counts distinct runs containing the spec (capped at the bucket's
+// total_runs). Naming borrows the MOBA pick-rate convention so it's familiar.
+const METRIC_OPTIONS: Array<{ key: string; label: string }> = [
+  { key: "picks", label: "Picks" },
+  { key: "runs", label: "Runs" },
+];
+
 // data-buckets carries all 5 buckets so client can switch without refetching
 const dataJson = JSON.stringify(buckets);
 ---
@@ -44,13 +77,31 @@ const dataJson = JSON.stringify(buckets);
         </button>
       ))}
     </nav>
-    <div class="filter-group spec-distribution-chart__role">
-      <label for={`spec-dist-role-${scope}`}>Role</label>
-      <select id={`spec-dist-role-${scope}`} data-role-select>
-        {ROLE_TABS.map((r) => (
-          <option value={r.key}>{r.label}</option>
-        ))}
-      </select>
+    <div class="spec-distribution-chart__selects">
+      <div class="filter-group">
+        <label for={`spec-dist-metric-${scope}`}>Metric</label>
+        <select id={`spec-dist-metric-${scope}`} data-metric-select>
+          {METRIC_OPTIONS.map((m) => (
+            <option value={m.key}>{m.label}</option>
+          ))}
+        </select>
+      </div>
+      <div class="filter-group">
+        <label for={`spec-dist-role-${scope}`}>Role</label>
+        <select id={`spec-dist-role-${scope}`} data-role-select>
+          {ROLE_TABS.map((r) => (
+            <option value={r.key}>{r.label}</option>
+          ))}
+        </select>
+      </div>
+      <div class="filter-group">
+        <label for={`spec-dist-dungeon-${scope}`}>Dungeon</label>
+        <select id={`spec-dist-dungeon-${scope}`} data-dungeon-select>
+          {DUNGEON_OPTIONS.map((d) => (
+            <option value={d.key}>{d.label}</option>
+          ))}
+        </select>
+      </div>
     </div>
   </div>
   <div class="spec-distribution-chart__canvas">

diff --git a/web/src/components/Stats/SpecDi
stributionChart/SpecDistributionChart.astro 
b/web/src/components/Stats/SpecDistributionC
hart/SpecDistributionChart.astro
index 3e24df1..b7f609c 100644
--- a/web/src/components/Stats/SpecDistribut
ionChart/SpecDistributionChart.astro
+++ b/web/src/components/Stats/SpecDistribut
ionChart/SpecDistributionChart.astro
@@ -1,15 +1,40 @@
 ---
 import "./SpecDistributionChart.scss";
-import type { StatsSpecCountEntry } from ".
./../../lib/types";
+import type { StatsSpecCountBucket } from "
../../../lib/types";
+import { DUNGEON_SLUG_TO_ID } from "../../.
./lib/dungeon-thresholds";
 
 interface Props {
-  buckets: Record<string, StatsSpecCountEnt
ry[]>;
+  buckets: Record<string, StatsSpecCountBuc
ket>;
   // unique scope id so multiple charts on 
a page can each find their own data island
   scope: string;
 }
 
 const { buckets, scope } = Astro.props;
 
+// Dungeon dropdown options. "all" is the d
efault (overall data); each dungeon
+// id maps to a per-dungeon slice via bucke
t.by_dungeon[id].
+const DUNGEON_OPTIONS: Array<{ key: string;
 label: string }> = [
+  { key: "all", label: "All Dungeons" },
+  { key: "2", label: "Temple of the Jade Se
rpent" },
+  { key: "56", label: "Stormstout Brewery" 
},
+  { key: "57", label: "Gate of the Setting 
Sun" },
+  { key: "58", label: "Shado-Pan Monastery"
 },
+  { key: "59", label: "Siege of Niuzao Temp
le" },
+  { key: "60", label: "Mogu'shan Palace" },
+  { key: "76", label: "Scholomance" },
+  { key: "77", label: "Scarlet Halls" },
+  { key: "78", label: "Scarlet Monastery" }
,
+];
+
+// Light sanity check at build time - every
 option (besides "all") matches the
+// canonical slug?id map. Catches a typo in
 either side early.
+for (const o of DUNGEON_OPTIONS) {
+  if (o.key === "all") continue;
+  if (!Object.values(DUNGEON_SLUG_TO_ID).in
cludes(parseInt(o.key, 10))) {
+    throw new Error(`Unknown dungeon id ${o
.key} in SpecDistributionChart`);
+  }
+}
+
 const BUCKET_TABS: Array<{ key: string; lab
el: string }> = [
   { key: "all_runs", label: "All Runs" },
   { key: "gold_runs", label: "Gold" },
@@ -25,6 +50,14 @@ const ROLE_TABS: Array<{ 
key: string; label: string }> = [
   { key: "dps", label: "DPS" },
 ];
 
+// "Picks" counts every spec slot (includes
 spec stacking, can exceed run count).
+// "Runs" counts distinct runs containing t
he spec (capped at the bucket's
+// total_runs). Naming borrows the MOBA pic
k-rate convention so it's familiar.
+const METRIC_OPTIONS: Array<{ key: string; 
label: string }> = [
+  { key: "picks", label: "Picks" },
+  { key: "runs", label: "Runs" },
+];
+
 // data-buckets carries all 5 buckets so cl
ient can switch without refetching
 const dataJson = JSON.stringify(buckets);
 ---
@@ -44,13 +77,31 @@ const dataJson = JSON.st
ringify(buckets);
         </button>
       ))}
     </nav>
-    <div class="filter-group spec-distribut
ion-chart__role">
-      <label for={`spec-dist-role-${scope}`
}>Role</label>
-      <select id={`spec-dist-role-${scope}`
} data-role-select>
-        {ROLE_TABS.map((r) => (
-          <option value={r.key}>{r.label}</
option>
-        ))}
-      </select>
+    <div class="spec-distribution-chart__se
lects">
+      <div class="filter-group">
+        <label for={`spec-dist-metric-${sco
pe}`}>Metric</label>
+        <select id={`spec-dist-metric-${sco
pe}`} data-metric-select>
+          {METRIC_OPTIONS.map((m) => (
+            <option value={m.key}>{m.label}
</option>
+          ))}
+        </select>
+      </div>
+      <div class="filter-group">
+        <label for={`spec-dist-role-${scope
}`}>Role</label>
+        <select id={`spec-dist-role-${scope
}`} data-role-select>
+          {ROLE_TABS.map((r) => (
+            <option value={r.key}>{r.label}
</option>
+          ))}
+        </select>
+      </div>
+      <div class="filter-group">
+        <label for={`spec-dist-dungeon-${sc
ope}`}>Dungeon</label>
+        <select id={`spec-dist-dungeon-${sc
ope}`} data-dungeon-select>
+          {DUNGEON_OPTIONS.map((d) => (
+            <option value={d.key}>{d.label}
</option>
+          ))}
+        </select>
+      </div>
     </div>
   </div>
   <div class="spec-distribution-chart__canv
as">
 

diff --git a/web/src/components/Stats/SpecDistributionChart/SpecDistributionChart.
scss b/web/src/components/Stats/SpecDistributionChart/SpecDistributionChart.scss
index 2155232..80fb77d 100644
--- a/web/src/components/Stats/SpecDistributionChart/SpecDistributionChart.scss
+++ b/web/src/components/Stats/SpecDistributionChart/SpecDistributionChart.scss
@@ -29,9 +29,12 @@
     gap: $spacing-xs;
   }
 
-  &__role {
-    // shrink the standard `.filter-group` slightly so the dropdown sits flush
-    // with the bucket tabs without bloating the controls row.
+  // Group of dropdowns (metric + role) on the right of the controls row.
+  &__selects {
+    display: flex;
+    flex-wrap: wrap;
+    gap: $spacing-md;
+    // sits flush with the bucket tabs on the row's baseline.
     margin-bottom: $spacing-xs;
   }
 

diff --git a/web/src/components/Stats/SpecDi
stributionChart/SpecDistributionChart.scss b
/web/src/components/Stats/SpecDistributionCh
art/SpecDistributionChart.scss
index 2155232..80fb77d 100644
--- a/web/src/components/Stats/SpecDistribut
ionChart/SpecDistributionChart.scss
+++ b/web/src/components/Stats/SpecDistribut
ionChart/SpecDistributionChart.scss
@@ -29,9 +29,12 @@
     gap: $spacing-xs;
   }
 
-  &__role {
-    // shrink the standard `.filter-group` 
slightly so the dropdown sits flush
-    // with the bucket tabs without bloatin
g the controls row.
+  // Group of dropdowns (metric + role) on 
the right of the controls row.
+  &__selects {
+    display: flex;
+    flex-wrap: wrap;
+    gap: $spacing-md;
+    // sits flush with the bucket tabs on t
he row's baseline.
     margin-bottom: $spacing-xs;
   }
 
 

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() {

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(
) {
 

diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 4ecc63c..5d5c55b 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -227,10 +227,23 @@ export interface StatsScope {
     "9_of_9_platinum": StatsCompletionTier;
     "9_of_9_title": StatsCompletionTier;
   };
-  spec_counts: Record<string, StatsSpecCountEntry[]>; // keys: "all_runs" | "gold
_runs" | "platinum_runs" | "title_runs" | "top_50_runs"
+  spec_counts: Record<string, StatsSpecCountBucket>; // keys: "all_runs" | "gold_
runs" | "platinum_runs" | "title_runs" | "top_50_runs"
   weekly_activity: StatsWeeklyActivityEntry[];
 }
 
+export interface StatsSpecCountBucket {
+  // Distinct runs in this bucket (denominator for the "runs with spec" metric).
+  total_runs: number;
+  entries: StatsSpecCountEntry[];
+  // Per-dungeon breakdown - same shape, keyed by numeric dungeon_id.
+  by_dungeon: Record<string, StatsDungeonSpecBucket>;
+}
+
+export interface StatsDungeonSpecBucket {
+  total_runs: number;
+  entries: StatsSpecCountEntry[];
+}
+
 export interface StatsCompletionTier {
   count: number;
   percentile_of_all_players: number;
@@ -242,7 +255,10 @@ export interface StatsSpecCountEntry {
   spec_id: number;
   class_name: string;
   spec_name: string;
+  // Total spec slot occurrences (e.g. 2 Combat Rogues in one run = 2).
   count: number;
+  // Distinct runs containing this spec (e.g. 2 Combat Rogues in one run = 1).
+  runs_with_spec: number;
 }
 
 export interface StatsWeeklyActivityEntry {

diff --git a/web/src/lib/types.ts b/web/src/
lib/types.ts
index 4ecc63c..5d5c55b 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -227,10 +227,23 @@ export interface Stats
Scope {
     "9_of_9_platinum": StatsCompletionTier;
     "9_of_9_title": StatsCompletionTier;
   };
-  spec_counts: Record<string, StatsSpecCoun
tEntry[]>; // keys: "all_runs" | "gold_runs"
 | "platinum_runs" | "title_runs" | "top_50_
runs"
+  spec_counts: Record<string, StatsSpecCoun
tBucket>; // keys: "all_runs" | "gold_runs" 
| "platinum_runs" | "title_runs" | "top_50_r
uns"
   weekly_activity: StatsWeeklyActivityEntry
[];
 }
 
+export interface StatsSpecCountBucket {
+  // Distinct runs in this bucket (denomina
tor for the "runs with spec" metric).
+  total_runs: number;
+  entries: StatsSpecCountEntry[];
+  // Per-dungeon breakdown - same shape, ke
yed by numeric dungeon_id.
+  by_dungeon: Record<string, StatsDungeonSp
ecBucket>;
+}
+
+export interface StatsDungeonSpecBucket {
+  total_runs: number;
+  entries: StatsSpecCountEntry[];
+}
+
 export interface StatsCompletionTier {
   count: number;
   percentile_of_all_players: number;
@@ -242,7 +255,10 @@ export interface StatsS
pecCountEntry {
   spec_id: number;
   class_name: string;
   spec_name: string;
+  // Total spec slot occurrences (e.g. 2 Co
mbat Rogues in one run = 2).
   count: number;
+  // Distinct runs containing this spec (e.
g. 2 Combat Rogues in one run = 1).
+  runs_with_spec: number;
 }
 
 export interface StatsWeeklyActivityEntry {
 
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET