fix(index-builder): correct series/season exempt queries, optimize playlist curation

- Fix deploy.go to query actual table names (series_games not series_matches,
  join through series_games for seasons instead of non-existent season_matches)
- Add playlist_matches table to exempt match IDs from R2 pruning
- Pre-build lookup maps for O(1) playlist match filtering instead of O(n²)
- Enhance home page featured replay to prefer AI-enriched matches
- Add enrichment test coverage (shouldEnrich criteria validation)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-21 16:47:13 -04:00
parent aa1c78c9d7
commit be3843d9ac
4 changed files with 723 additions and 21 deletions

View file

@ -14,17 +14,19 @@ import (
"time"
)
// fetchExemptMatchIDs retrieves match IDs that should never be pruned (from series, seasons, playlists)
// fetchExemptMatchIDs retrieves match IDs that should never be pruned (from
// series, seasons, and playlists).
func fetchExemptMatchIDs(ctx context.Context, db *sql.DB, outputDir string) (map[string]bool, error) {
exempt := make(map[string]bool)
if db != nil {
// Matches in active series
// Matches in active/pending series (series_games, not series_matches)
seriesQuery := `
SELECT DISTINCT sm.match_id
FROM series_matches sm
JOIN series s ON sm.series_id = s.id
SELECT DISTINCT sg.match_id
FROM series_games sg
JOIN series s ON sg.series_id = s.id
WHERE s.status IN ('active', 'pending')
AND sg.match_id IS NOT NULL
`
rows, err := db.QueryContext(ctx, seriesQuery)
if err == nil {
@ -37,13 +39,15 @@ func fetchExemptMatchIDs(ctx context.Context, db *sql.DB, outputDir string) (map
rows.Close()
}
// Matches in active seasons
// Matches in active seasons (via series → series_games)
seasonQuery := `
SELECT DISTINCT match_id
FROM season_matches
WHERE season_id IN (
SELECT DISTINCT sg.match_id
FROM series_games sg
JOIN series s ON sg.series_id = s.id
WHERE s.season_id IN (
SELECT id FROM seasons WHERE ends_at IS NULL OR ends_at > NOW()
)
AND sg.match_id IS NOT NULL
`
rows, err = db.QueryContext(ctx, seasonQuery)
if err == nil {
@ -55,9 +59,22 @@ func fetchExemptMatchIDs(ctx context.Context, db *sql.DB, outputDir string) (map
}
rows.Close()
}
// Matches in persisted playlists (playlist_matches table)
playlistQuery := `SELECT DISTINCT match_id FROM playlist_matches`
rows, err = db.QueryContext(ctx, playlistQuery)
if err == nil {
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
exempt[id] = true
}
}
rows.Close()
}
}
// Matches in generated playlist files (file-based playlists from index builder)
// Also read from generated playlist files (covers cases where DB persist failed)
playlistMatchIDs := fetchPlaylistMatchIDsFromFiles(outputDir)
for id := range playlistMatchIDs {
exempt[id] = true

View file

@ -0,0 +1,603 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
)
// ── shouldEnrich tests ─────────────────────────────────────────────────────
func TestShouldEnrich_BackAndForth(t *testing.T) {
m := MatchData{
ID: "m_backforth",
WinnerID: "bot_a",
TurnCount: 250,
Participants: []ParticipantData{
{BotID: "bot_a", Score: 5, Won: true, PreMatchRating: 1500},
{BotID: "bot_b", Score: 4, Won: false, PreMatchRating: 1500},
},
}
data := &IndexData{
Bots: []BotData{
{ID: "bot_a", Rating: 1500},
{ID: "bot_b", Rating: 1500},
},
}
criteria, ok := shouldEnrich(m, data)
if !ok {
t.Fatal("expected match to be selected for enrichment")
}
found := false
for _, c := range criteria {
if c == "back_and_forth" {
found = true
}
}
if !found {
t.Errorf("expected back_and_forth criterion, got %v", criteria)
}
}
func TestShouldEnrich_Upset(t *testing.T) {
m := MatchData{
ID: "m_upset",
WinnerID: "bot_weak",
TurnCount: 300,
Participants: []ParticipantData{
{BotID: "bot_weak", Score: 5, Won: true, PreMatchRating: 1200},
{BotID: "bot_strong", Score: 2, Won: false, PreMatchRating: 1600},
},
}
data := &IndexData{
Bots: []BotData{
{ID: "bot_weak", Rating: 1200},
{ID: "bot_strong", Rating: 1600},
},
}
criteria, ok := shouldEnrich(m, data)
if !ok {
t.Fatal("expected upset match to be selected for enrichment")
}
upsetFound := false
for _, c := range criteria {
if c == "upset_400" {
upsetFound = true
}
}
if !upsetFound {
t.Errorf("expected upset_400 criterion, got %v", criteria)
}
}
func TestShouldEnrich_EvolutionMilestone(t *testing.T) {
// getBotRank returns index+1 in the Bots slice, so evo_bot at index 2 = rank 3
m := MatchData{
ID: "m_evomilestone",
WinnerID: "evo_bot",
TurnCount: 200,
Participants: []ParticipantData{
{BotID: "evo_bot", Score: 5, Won: true, PreMatchRating: 1700},
{BotID: "bot_other", Score: 3, Won: false, PreMatchRating: 1650},
},
}
data := &IndexData{
Bots: []BotData{
{ID: "bot_top1", Rating: 1800},
{ID: "bot_top2", Rating: 1750},
{ID: "evo_bot", Rating: 1700, Evolved: true},
{ID: "bot_other", Rating: 1650},
},
}
criteria, ok := shouldEnrich(m, data)
if !ok {
t.Fatal("expected evolution milestone match to be selected")
}
found := false
for _, c := range criteria {
if c == "evolution_milestone" {
found = true
}
}
if !found {
t.Errorf("expected evolution_milestone criterion, got %v", criteria)
}
}
func TestShouldEnrich_HighInterest(t *testing.T) {
m := MatchData{
ID: "m_interesting",
WinnerID: "bot_a",
TurnCount: 450,
Participants: []ParticipantData{
{BotID: "bot_a", Score: 5, Won: true, PreMatchRating: 1800},
{BotID: "bot_b", Score: 4, Won: false, PreMatchRating: 1700},
},
}
data := &IndexData{
Bots: []BotData{
{ID: "bot_a", Rating: 1800},
{ID: "bot_b", Rating: 1700},
},
}
_, ok := shouldEnrich(m, data)
if !ok {
t.Fatal("expected high interest match to be selected")
}
}
func TestShouldEnrich_BoringMatchNotSelected(t *testing.T) {
m := MatchData{
ID: "m_trulyboring",
WinnerID: "bot_a",
TurnCount: 50,
Participants: []ParticipantData{
{BotID: "bot_a", Score: 10, Won: true, PreMatchRating: 1500},
{BotID: "bot_b", Score: 8, Won: false, PreMatchRating: 1510},
},
}
data := &IndexData{
Bots: []BotData{
{ID: "bot_a", Rating: 1500},
{ID: "bot_b", Rating: 1510},
},
}
_, ok := shouldEnrich(m, data)
if ok {
t.Error("expected boring match to not be selected for enrichment")
}
}
func TestShouldEnrich_NoWinner(t *testing.T) {
m := MatchData{
ID: "m_nowinner",
WinnerID: "",
TurnCount: 300,
Participants: []ParticipantData{
{BotID: "bot_a", Score: 5},
{BotID: "bot_b", Score: 5},
},
}
data := &IndexData{}
_, ok := shouldEnrich(m, data)
if ok {
t.Error("expected match with no winner to not be selected")
}
}
func TestShouldEnrich_TooFewParticipants(t *testing.T) {
m := MatchData{
ID: "m_onebot",
WinnerID: "bot_a",
TurnCount: 300,
Participants: []ParticipantData{
{BotID: "bot_a", Score: 5, Won: true},
},
}
data := &IndexData{}
_, ok := shouldEnrich(m, data)
if ok {
t.Error("expected match with < 2 participants to not be selected")
}
}
// ── parseCommentaryResponse tests ──────────────────────────────────────────
func TestParseCommentaryResponse(t *testing.T) {
input := "1|setup|The bots face off on a 60x60 grid\n" +
"42|action|First contact near the central energy cluster\n" +
"87|climax|SwarmBot breaks through the eastern corridor\n" +
"200|reaction|GathererBot attempts to regroup\n" +
"499|denouement|The match ends with a decisive victory"
entries := parseCommentaryResponse(input)
if len(entries) != 5 {
t.Fatalf("expected 5 entries, got %d", len(entries))
}
if entries[0].Turn != 1 {
t.Errorf("entry 0 turn: got %d, want 1", entries[0].Turn)
}
if entries[0].Type != "setup" {
t.Errorf("entry 0 type: got %q, want setup", entries[0].Type)
}
if entries[0].Text != "The bots face off on a 60x60 grid" {
t.Errorf("entry 0 text: got %q", entries[0].Text)
}
if entries[2].Type != "climax" {
t.Errorf("entry 2 type: got %q, want climax", entries[2].Type)
}
if entries[4].Turn != 499 {
t.Errorf("entry 4 turn: got %d, want 499", entries[4].Turn)
}
if entries[4].Type != "denouement" {
t.Errorf("entry 4 type: got %q, want denouement", entries[4].Type)
}
}
func TestParseCommentaryResponse_SkipsInvalid(t *testing.T) {
input := "# This is a comment\n" +
"1|setup|Valid entry\n" +
"\n" +
"INVALID_LINE\n" +
"42|action|Another valid entry\n" +
"bad_turn|action|Bad turn number\n" +
"100|invalid_type|Defaults to action"
entries := parseCommentaryResponse(input)
if len(entries) != 3 {
t.Fatalf("expected 3 entries (skipping comment, blank, invalid line, bad turn), got %d", len(entries))
}
if entries[0].Turn != 1 {
t.Errorf("entry 0 turn: got %d, want 1", entries[0].Turn)
}
if entries[1].Turn != 42 {
t.Errorf("entry 1 turn: got %d, want 42", entries[1].Turn)
}
if entries[2].Turn != 100 {
t.Errorf("entry 2 turn: got %d, want 100", entries[2].Turn)
}
if entries[2].Type != "action" {
t.Errorf("entry 2 type: got %q, want action (default for invalid type)", entries[2].Type)
}
}
func TestParseCommentaryResponse_Empty(t *testing.T) {
entries := parseCommentaryResponse("")
if len(entries) != 0 {
t.Errorf("expected 0 entries for empty input, got %d", len(entries))
}
}
func TestParseCommentaryResponse_SlashSlashComments(t *testing.T) {
input := "// Another kind of comment\n10|action|Real entry"
entries := parseCommentaryResponse(input)
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].Turn != 10 {
t.Errorf("entry 0 turn: got %d, want 10", entries[0].Turn)
}
}
// ── countWinProbCrossings tests ────────────────────────────────────────────
func TestCountWinProbCrossings_NoCrossings(t *testing.T) {
wp := [][]float64{
{0.8, 0.2},
{0.85, 0.15},
{0.9, 0.1},
{0.88, 0.12},
}
if n := countWinProbCrossings(wp); n != 0 {
t.Errorf("expected 0 crossings, got %d", n)
}
}
func TestCountWinProbCrossings_ThreeCrossings(t *testing.T) {
wp := [][]float64{
{0.7, 0.3},
{0.4, 0.6}, // crossing 1
{0.3, 0.7},
{0.6, 0.4}, // crossing 2
{0.8, 0.2},
{0.4, 0.6}, // crossing 3
{0.3, 0.7},
}
if n := countWinProbCrossings(wp); n != 3 {
t.Errorf("expected 3 crossings, got %d", n)
}
}
func TestCountWinProbCrossings_Empty(t *testing.T) {
if n := countWinProbCrossings(nil); n != 0 {
t.Errorf("expected 0 for nil, got %d", n)
}
if n := countWinProbCrossings([][]float64{}); n != 0 {
t.Errorf("expected 0 for empty, got %d", n)
}
if n := countWinProbCrossings([][]float64{{0.5, 0.5}}); n != 0 {
t.Errorf("expected 0 for single entry, got %d", n)
}
}
func TestCountWinProbCrossings_Exactly50(t *testing.T) {
wp := [][]float64{
{0.4, 0.6},
{0.5, 0.5}, // crosses up to >= 0.5
}
if n := countWinProbCrossings(wp); n != 1 {
t.Errorf("expected 1 crossing at 0.5 boundary, got %d", n)
}
}
// ── buildCommentaryPrompt tests ────────────────────────────────────────────
func TestBuildCommentaryPrompt(t *testing.T) {
m := MatchData{
ID: "m_test",
WinnerID: "bot_a",
TurnCount: 300,
Participants: []ParticipantData{
{BotID: "bot_a", Score: 5, Won: true, PreMatchRating: 1800},
{BotID: "bot_b", Score: 3, Won: false, PreMatchRating: 1600},
},
}
replay := makeTestReplayStruct("SwarmBot", "HunterBot", 0, "turn_limit", 300, []int{5, 3})
data := &IndexData{
Bots: []BotData{
{ID: "bot_a", Name: "SwarmBot", Rating: 1800},
{ID: "bot_b", Name: "HunterBot", Rating: 1600},
},
}
prompt := buildCommentaryPrompt(m, replay, []string{"upset_200", "back_and_forth"}, data)
checks := []string{
"AI Code Battle commentator",
"TURN|TYPE|TEXT",
"SwarmBot vs HunterBot",
"turn_limit",
"300 turns",
"upset_200",
"back_and_forth",
"SwarmBot",
"HunterBot",
}
for _, check := range checks {
if !strings.Contains(prompt, check) {
t.Errorf("prompt missing expected substring %q", check)
}
}
}
func TestBuildCommentaryPrompt_WithCriticalMoments(t *testing.T) {
m := MatchData{
ID: "m_cm",
WinnerID: "bot_a",
TurnCount: 200,
Participants: []ParticipantData{
{BotID: "bot_a", Score: 4, Won: true, PreMatchRating: 1500},
{BotID: "bot_b", Score: 2, Won: false, PreMatchRating: 1500},
},
}
replay := makeTestReplayStruct("BotA", "BotB", 0, "elimination", 200, []int{4, 2})
data := &IndexData{
Bots: []BotData{
{ID: "bot_a", Name: "BotA", Rating: 1500},
{ID: "bot_b", Name: "BotB", Rating: 1500},
},
}
prompt := buildCommentaryPrompt(m, replay, []string{"high_interest"}, data)
if !strings.Contains(prompt, "Critical moments:") {
t.Error("prompt missing critical moments header")
}
if !strings.Contains(prompt, "Turn 87") {
t.Error("prompt missing critical moment turn 87")
}
if !strings.Contains(prompt, "6 bots killed") {
t.Error("prompt missing critical moment description")
}
if !strings.Contains(prompt, "Turn 150") {
t.Error("prompt missing critical moment turn 150")
}
}
func TestBuildCommentaryPrompt_WithWinProb(t *testing.T) {
m := MatchData{
ID: "m_wp",
WinnerID: "bot_a",
TurnCount: 100,
Participants: []ParticipantData{
{BotID: "bot_a", Score: 3, Won: true, PreMatchRating: 1500},
{BotID: "bot_b", Score: 2, Won: false, PreMatchRating: 1500},
},
}
replay := makeTestReplayStruct("BotA", "BotB", 0, "turn_limit", 100, []int{3, 2})
replay.WinProb = [][]float64{
{0.5, 0.5},
{0.3, 0.7},
{0.6, 0.4},
{0.2, 0.8},
{0.8, 0.2},
}
data := &IndexData{
Bots: []BotData{
{ID: "bot_a", Name: "BotA", Rating: 1500},
{ID: "bot_b", Name: "BotB", Rating: 1500},
},
}
prompt := buildCommentaryPrompt(m, replay, []string{"back_and_forth"}, data)
if !strings.Contains(prompt, "crossed 0.5:") {
t.Error("prompt missing win prob crossings info")
}
if !strings.Contains(prompt, "Biggest swing:") {
t.Error("prompt missing biggest swing info")
}
}
// ── EnrichedCommentary JSON round-trip ─────────────────────────────────────
func TestEnrichedCommentaryJSON(t *testing.T) {
comm := &EnrichedCommentary{
MatchID: "m_test",
Generated: "2026-04-21T12:00:00Z",
Criteria: []string{"upset_300", "back_and_forth"},
Entries: []CommentaryEntry{
{Turn: 1, Text: "Opening moves", Type: "setup"},
{Turn: 87, Text: "Major engagement", Type: "climax"},
{Turn: 499, Text: "Match concludes", Type: "denouement"},
},
}
data, err := json.Marshal(comm)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var parsed EnrichedCommentary
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if parsed.MatchID != "m_test" {
t.Errorf("match_id: got %q, want m_test", parsed.MatchID)
}
if len(parsed.Entries) != 3 {
t.Fatalf("entries: got %d, want 3", len(parsed.Entries))
}
if parsed.Entries[1].Turn != 87 {
t.Errorf("entry 1 turn: got %d, want 87", parsed.Entries[1].Turn)
}
if parsed.Entries[1].Type != "climax" {
t.Errorf("entry 1 type: got %q, want climax", parsed.Entries[1].Type)
}
if len(parsed.Criteria) != 2 {
t.Errorf("criteria: got %d, want 2", len(parsed.Criteria))
}
}
// ── enrichReplays integration test (LLM nil) ───────────────────────────────
func TestEnrichReplays_NilLLM(t *testing.T) {
ctx := context.Background()
data := &IndexData{
Matches: []MatchData{
{
ID: "m_test",
WinnerID: "bot_a",
TurnCount: 300,
Participants: []ParticipantData{
{BotID: "bot_a", Score: 3, Won: true, PreMatchRating: 1500},
{BotID: "bot_b", Score: 2, Won: false, PreMatchRating: 1500},
},
},
},
}
err := enrichReplays(ctx, data, &Config{}, nil)
if err != nil {
t.Errorf("enrichReplays with nil LLM should not error, got: %v", err)
}
}
// ── isEvolved helper test ──────────────────────────────────────────────────
func TestIsEvolved(t *testing.T) {
data := &IndexData{
Bots: []BotData{
{ID: "bot_human", Evolved: false},
{ID: "bot_evo", Evolved: true},
},
}
if isEvolved("bot_human", data) {
t.Error("human bot should not be marked as evolved")
}
if !isEvolved("bot_evo", data) {
t.Error("evolved bot should be marked as evolved")
}
if isEvolved("bot_missing", data) {
t.Error("missing bot should not be marked as evolved")
}
}
// ── makeTestReplayStruct creates the anonymous struct used by buildCommentaryPrompt ─
func makeTestReplayStruct(p0Name, p1Name string, winner int, reason string, turns int, scores []int) struct {
WinProb [][]float64 `json:"win_prob"`
CriticalMoments []struct {
Turn int `json:"turn"`
Delta float64 `json:"delta"`
Description string `json:"description"`
} `json:"critical_moments"`
Result struct {
Winner int `json:"winner"`
Reason string `json:"reason"`
Turns int `json:"turns"`
Scores []int `json:"scores"`
} `json:"result"`
Players []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"players"`
Turns []struct {
Turn int `json:"turn"`
Events []struct {
Type string `json:"type"`
Turn int `json:"turn"`
Details any `json:"details"`
} `json:"events"`
Scores []int `json:"scores"`
} `json:"turns"`
} {
var r struct {
WinProb [][]float64 `json:"win_prob"`
CriticalMoments []struct {
Turn int `json:"turn"`
Delta float64 `json:"delta"`
Description string `json:"description"`
} `json:"critical_moments"`
Result struct {
Winner int `json:"winner"`
Reason string `json:"reason"`
Turns int `json:"turns"`
Scores []int `json:"scores"`
} `json:"result"`
Players []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"players"`
Turns []struct {
Turn int `json:"turn"`
Events []struct {
Type string `json:"type"`
Turn int `json:"turn"`
Details any `json:"details"`
} `json:"events"`
Scores []int `json:"scores"`
} `json:"turns"`
}
r.Players = []struct {
ID int `json:"id"`
Name string `json:"name"`
}{{ID: 0, Name: p0Name}, {ID: 1, Name: p1Name}}
r.Result.Winner = winner
r.Result.Reason = reason
r.Result.Turns = turns
r.Result.Scores = scores
r.CriticalMoments = []struct {
Turn int `json:"turn"`
Delta float64 `json:"delta"`
Description string `json:"description"`
}{
{Turn: 87, Delta: 0.22, Description: "6 bots killed in eastern engagement"},
{Turn: 150, Delta: -0.31, Description: "Core captured by " + p0Name},
}
return r
}

View file

@ -382,6 +382,10 @@ func generatePredictionsIndex(data *IndexData, outputDir string) error {
func generatePlaylists(data *IndexData, outputDir string, botNameMap map[string]string) error {
playlistsDir := filepath.Join(outputDir, "data", "playlists")
// Pre-build lookup maps for O(1) playlist curation instead of O(n^2) per match.
firstMatchPerBot := buildFirstMatchPerBot(data.Matches)
pairFrequency := buildPairFrequency(data.Matches)
type playlistDef struct {
slug string
title string
@ -481,7 +485,7 @@ func generatePlaylists(data *IndexData, outputDir string, botNameMap map[string]
description: "The most closely contested matchups between frequent opponents",
category: "rivalry",
filter: func(m MatchData) bool {
return isRivalryMatch(m, data)
return isRivalryMatchFast(m, pairFrequency)
},
sort: func(matches []MatchData) {
sortSlice(matches, func(i, j int) bool {
@ -512,7 +516,7 @@ func generatePlaylists(data *IndexData, outputDir string, botNameMap map[string]
description: "First matches of newly registered bots — watch their opening games",
category: "tutorial",
filter: func(m MatchData) bool {
return isNewBotDebut(m, data)
return isNewBotDebutFast(m, firstMatchPerBot)
},
sort: func(matches []MatchData) {
// Newest debuts first
@ -1114,6 +1118,69 @@ func isEvolutionBreakthrough(m MatchData, data *IndexData) bool {
return false
}
// buildFirstMatchPerBot returns a map from botID to the matchID of their earliest
// completed match. O(n*p) where n=matches, p=avg participants.
func buildFirstMatchPerBot(matches []MatchData) map[string]string {
first := make(map[string]string)
firstTime := make(map[string]time.Time)
for _, m := range matches {
if m.CompletedAt.IsZero() || m.WinnerID == "" {
continue
}
for _, p := range m.Participants {
if t, ok := firstTime[p.BotID]; !ok || m.CompletedAt.Before(t) {
firstTime[p.BotID] = m.CompletedAt
first[p.BotID] = m.ID
}
}
}
return first
}
// isNewBotDebutFast checks if any participant's earliest completed match is this one,
// using a pre-built lookup map.
func isNewBotDebutFast(m MatchData, firstMatchPerBot map[string]string) bool {
if m.WinnerID == "" {
return false
}
for _, p := range m.Participants {
if firstMatchPerBot[p.BotID] == m.ID {
return true
}
}
return false
}
// buildPairFrequency returns a map from "botA:botB" (sorted) to the count of
// 2-player matches between them. O(n) where n=matches.
func buildPairFrequency(matches []MatchData) map[string]int {
freq := make(map[string]int)
for _, m := range matches {
if len(m.Participants) != 2 {
continue
}
a, b := m.Participants[0].BotID, m.Participants[1].BotID
if a > b {
a, b = b, a
}
freq[a+":"+b]++
}
return freq
}
// isRivalryMatchFast checks if a 2-player match is between frequent opponents,
// using a pre-built pair frequency map.
func isRivalryMatchFast(m MatchData, pairFrequency map[string]int) bool {
if len(m.Participants) != 2 || m.WinnerID == "" {
return false
}
a, b := m.Participants[0].BotID, m.Participants[1].BotID
if a > b {
a, b = b, a
}
return pairFrequency[a+":"+b] >= 3
}
// isRivalryMatch detects matches between bots that have played each other frequently.
// Builds a frequency map from all matches and checks if this pair qualifies.
func isRivalryMatch(m MatchData, data *IndexData) bool {

View file

@ -6,6 +6,7 @@ import {
fetchEvolutionMeta,
fetchSeasonIndex,
fetchMatchIndex,
fetchEnrichedIndex,
type Season,
type MatchSummary
} from '../api-types';
@ -51,15 +52,29 @@ async function fetchWithCache<T>(
}
}
// Find featured replay (highest-viewed recent match)
function findFeaturedReplay(matches: MatchSummary[]): MatchSummary | null {
const completed = matches.filter(m => m.completed_at && m.participants.length === 2);
if (completed.length === 0) return null;
// For now, just return the most recent completed match
// TODO: Add view_count to match index and sort by that
return completed.sort((a, b) =>
// Find featured replay — prefer enriched/AI-commentary matches, then most recent
async function findFeaturedReplay(matches: MatchSummary[]): Promise<{ match: MatchSummary | null; enriched: boolean }> {
const completed = matches.filter(m => m.completed_at && m.participants.length >= 2);
if (completed.length === 0) return { match: null, enriched: false };
// Sort by most recent first
const sorted = [...completed].sort((a, b) =>
new Date(b.completed_at!).getTime() - new Date(a.completed_at!).getTime()
)[0] || null;
);
// Try to find an enriched match among recent replays
try {
const enrichedIndex = await fetchEnrichedIndex();
const enrichedIDs = new Set(enrichedIndex.entries.map(e => e.match_id));
const enrichedMatch = sorted.find(m => enrichedIDs.has(m.id));
if (enrichedMatch) {
return { match: enrichedMatch, enriched: true };
}
} catch {
// enriched index not available — fall through
}
return { match: sorted[0], enriched: false };
}
// Format time remaining
@ -115,7 +130,7 @@ export async function renderHomePage(): Promise<void> {
const top5 = leaderboardData.entries.slice(0, 5);
const latestStories = blogData.posts.slice(0, 3);
const featuredPlaylists = playlistsData.playlists.slice(0, 6);
const featuredReplay = findFeaturedReplay(matchesData.matches);
const { match: featuredReplay } = await findFeaturedReplay(matchesData.matches);
const activeSeason = seasonData.active_season;
const seasonProgress = getSeasonProgress(activeSeason);