feat(evolution, web): add live match counter per plan §16.18

- Add matches_today and active_bots fields to LiveData Totals (evolver)
- Query matches table for COUNT(*) WHERE completed_at >= today
- Query bots table for COUNT(*) WHERE status = 'active'
- Add fields to index builder EvolutionMeta struct
- Update homepage to render "X matches today · Y bots active · Gen #Z evolving"
- Add CSS styling for .home-live-stats section

Closes: bf-4m8mo
This commit is contained in:
jedarden 2026-05-26 19:57:57 -04:00
parent db54067f56
commit f35477dd96
4 changed files with 72 additions and 2 deletions

View file

@ -133,6 +133,8 @@ type Totals struct {
HighestEvolvedRating int `json:"highest_evolved_rating"`
EvolvedInTop10 int `json:"evolved_in_top_10"`
MutationsPerHour float64 `json:"mutations_per_hour"`
MatchesToday int `json:"matches_today"` // plan §16.18: matches completed today
ActiveBots int `json:"active_bots"` // plan §16.18: active bot count
}
// LiveData is the full evolution dashboard payload written to live.json (plan §14 format).
@ -312,6 +314,24 @@ func fillTotals(ctx context.Context, db *sql.DB, data *LiveData) error {
mutationsLastHour = 0
}
// Matches today (plan §16.18: completed matches since midnight UTC)
var matchesToday int
err = db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM matches
WHERE completed_at >= $1::date`, today).Scan(&matchesToday)
if err != nil {
matchesToday = 0
}
// Active bots (plan §16.18: bots with status = 'active')
var activeBots int
err = db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM bots
WHERE status = 'active'`).Scan(&activeBots)
if err != nil {
activeBots = 0
}
data.Totals = Totals{
GenerationsTotal: maxGen,
CandidatesToday: candidatesToday,
@ -320,6 +340,8 @@ func fillTotals(ctx context.Context, db *sql.DB, data *LiveData) error {
HighestEvolvedRating: highestRating,
EvolvedInTop10: top10Count,
MutationsPerHour: round3(float64(mutationsLastHour)),
MatchesToday: matchesToday,
ActiveBots: activeBots,
}
return nil

View file

@ -1035,6 +1035,8 @@ type EvolutionMeta struct {
TotalPromoted int `json:"total_promoted"` // all-time promoted count
PromotionRate float64 `json:"promotion_rate"` // promoted/total
UpdatedAt string `json:"updated_at"`
MatchesToday int `json:"matches_today"` // plan §16.18: matches completed today
ActiveBots int `json:"active_bots"` // plan §16.18: active bot count
}
// EvolvedBotRating represents an evolved bot's rating info
@ -1091,6 +1093,8 @@ func fetchEvolutionMeta(ctx context.Context, db *sql.DB) (*EvolutionMeta, error)
TotalPromoted: 0,
PromotionRate: 0,
UpdatedAt: updatedAt,
MatchesToday: 0,
ActiveBots: 0,
}, nil
}
@ -1140,6 +1144,28 @@ func fetchEvolutionMeta(ctx context.Context, db *sql.DB) (*EvolutionMeta, error)
// Count evolved bots in top 10
meta.Top10Count = len(meta.BestRatings)
// Fetch matches today (plan §16.18: completed matches since midnight UTC)
var matchesToday int
matchErr := db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM matches
WHERE completed_at >= CURRENT_DATE
`).Scan(&matchesToday)
if matchErr != nil {
matchesToday = 0
}
meta.MatchesToday = matchesToday
// Fetch active bots count (plan §16.18: bots with status = 'active')
var activeBots int
botErr := db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM bots
WHERE status = 'active'
`).Scan(&activeBots)
if botErr != nil {
activeBots = 0
}
meta.ActiveBots = activeBots
meta.UpdatedAt = updatedAt
return &meta, nil
}

View file

@ -449,13 +449,16 @@ export interface EvolutionMeta {
promoted_today: number;
top_10_count: number;
updated_at: string;
matches_today?: number; // plan §16.18: matches completed today
active_bots?: number; // plan §16.18: active bot count
}
export async function fetchEvolutionMeta(): Promise<EvolutionMeta> {
return swr('evolution-meta', async () => {
const response = await fetch('/data/evolution/meta.json');
if (!response.ok) {
return { generation: 0, promoted_today: 0, top_10_count: 0, updated_at: '' };
const fallback: EvolutionMeta = { generation: 0, promoted_today: 0, top_10_count: 0, updated_at: '', matches_today: 0, active_bots: 0 };
return fallback;
}
return response.json();
});

View file

@ -146,6 +146,8 @@ export async function renderHomePage(): Promise<void> {
promoted_today: 0,
top_10_count: 0,
updated_at: '',
matches_today: 0,
active_bots: 0,
})),
fetchSeasonIndex().catch(() => ({
updated_at: '',
@ -200,7 +202,8 @@ export async function renderHomePage(): Promise<void> {
const evoHtml = lazySection(
'home-evo',
`<section class="home-evo"><div class="home-evo-info"><span class="home-evo-icon">&#129516;</span><span class="home-evo-text"><strong>Evolution Observatory</strong> &mdash; Gen #${evolutionMeta.generation}${evolutionMeta.promoted_today > 0 ? ` &middot; ${evolutionMeta.promoted_today} promoted today` : ''}${evolutionMeta.top_10_count > 0 ? ` &middot; ${evolutionMeta.top_10_count} in top 10` : ''}</span></div><a href="#/evolution" class="btn small secondary">Watch evolution live &rarr;</a></section>`,
`<section class="home-evo"><div class="home-evo-info"><span class="home-evo-icon">&#129516;</span><span class="home-evo-text"><strong>Evolution Observatory</strong> &mdash; Gen #${evolutionMeta.generation}${evolutionMeta.promoted_today > 0 ? ` &middot; ${evolutionMeta.promoted_today} promoted today` : ''}${evolutionMeta.top_10_count > 0 ? ` &middot; ${evolutionMeta.top_10_count} in top 10` : ''}</span></div><a href="#/evolution" class="btn small secondary">Watch evolution live &rarr;</a></section>
<div class="home-live-stats"><span class="home-live-icon">&#9876;</span><span class="home-live-text">${(evolutionMeta.matches_today ?? 0).toLocaleString()} matches today &middot; ${(evolutionMeta.active_bots ?? 0).toLocaleString()} bots active &middot; Gen #${evolutionMeta.generation} evolving</span></div>`,
{ placeholder: '<div class="lazy-placeholder" style="min-height:60px"></div>' }
);
@ -535,6 +538,22 @@ export async function renderHomePage(): Promise<void> {
}
.home-evo-text strong { color: var(--text-primary); }
/* Live stats bar (plan §16.18) */
.home-live-stats {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.home-live-icon { font-size: 1rem; }
.home-live-text {
color: var(--text-muted);
font-size: 0.75rem;
}
.home-empty {
color: var(--text-muted);
text-align: center;