March Madness 2026 Interactive Dashboard — Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a static interactive dashboard on tylerjamesburch.com that displays Bayesian bracket forecasts with daily-updating predictions, powered by Plotly.js and fed by JSON snapshots exported from the march_madness model.

Architecture: Three subsystems: (1) src/export.py in march_madness repo converts model outputs to compact JSON snapshots, (2) a Jekyll page + vanilla JS dashboard in the site repo renders interactive Plotly charts from those JSON files, (3) a GitHub Actions workflow in march_madness automates daily model re-fitting and pushes updated JSON to the site repo. The baseline snapshot is generated locally from existing model results.

Tech Stack: Python (existing PyMC/ArviZ model), Plotly.js 2.35.0 (CDN), vanilla JavaScript, Jekyll/Minimal Mistakes, GitHub Actions


File Structure

march_madness repo (new/modified files)

File Action Responsibility
src/export.py Create JSON export: snapshots, bracket structure, team metadata, odds timeline, team branding
.github/workflows/daily-update.yml Create Daily cron pipeline: fetch scores, fit model, export JSON, push to site repo

tjburch.github.io repo (new/modified files)

File Action Responsibility
_posts/2026-03-16-march-madness-dashboard.md Create Jekyll page with dashboard HTML structure
assets/js/march-madness-dashboard.js Create Plotly.js chart rendering, data loading, interactivity
assets/data/march-madness-2026/mens/snapshots/2026-03-16.json Create Baseline men’s snapshot (generated by export.py)
assets/data/march-madness-2026/womens/snapshots/2026-03-16.json Create Baseline women’s snapshot
assets/data/march-madness-2026/mens/latest.json Create Symlink/copy of most recent snapshot
assets/data/march-madness-2026/womens/latest.json Create Symlink/copy of most recent snapshot
assets/data/march-madness-2026/mens/odds_timeline.json Create Championship odds over time (men’s)
assets/data/march-madness-2026/womens/odds_timeline.json Create Championship odds over time (women’s)
assets/data/march-madness-2026/team_branding.json Create ESPN logos + school colors for all teams
_sass/custom.scss Modify Dashboard-specific styles appended

Task 1: Data Export Module (src/export.py)

Files:

  • Create: /Users/tburch/Developer/march_madness/src/export.py

This task builds the Python module that converts existing model outputs into the dashboard JSON format defined in the spec.

  • Step 1: Create export_snapshot() function

Write the core export function that takes model data, inference data, simulation results, and bracket structure, and produces a single snapshot JSON file per the spec schema.

# src/export.py
"""Export model results to dashboard JSON format."""

import json
import numpy as np
import pandas as pd
from pathlib import Path
from scipy.stats import norm
from datetime import datetime, timezone


def export_snapshot(
    data: dict,
    theta_samples: np.ndarray,
    sigma_samples: np.ndarray,
    sim_results: dict,
    bracket_struct: dict,
    date: str,
    actual_results: dict | None = None,
    output_dir: Path = Path("."),
) -> Path:
    """Export a single day's snapshot as JSON.

    Args:
        data: from build_model_data() — team_ids, team_names, etc.
        theta_samples: (n_samples, n_teams) posterior theta
        sigma_samples: (n_samples,) posterior sigma
        sim_results: from simulate_tournament() — advancement df, all_results
        bracket_struct: from build_bracket_structure()
        date: snapshot date string "YYYY-MM-DD"
        actual_results: known tournament results {slot: {winner: team_id, score: "XX-YY"}}
        output_dir: directory to write snapshot file

    Returns: Path to the written file
    """
    if actual_results is None:
        actual_results = {}

    team_id_to_idx = {int(tid): i for i, tid in enumerate(data["team_ids"])}
    seeds_df = bracket_struct["seeds_df"]
    advancement = sim_results["advancement"]

    # Build teams section — only tournament teams
    teams = {}
    for _, seed_row in seeds_df.iterrows():
        tid = int(seed_row["TeamID"])
        idx = team_id_to_idx.get(tid)
        if idx is None:
            continue
        theta_mean = float(theta_samples[:, idx].mean())
        theta_std = float(theta_samples[:, idx].std())

        adv_row = advancement[advancement["TeamID"] == tid]
        eliminated = False
        eliminated_round = None
        if len(adv_row) > 0:
            adv_row = adv_row.iloc[0]
            # Check actual_results to see if eliminated
            for slot, result in actual_results.items():
                if result and result.get("winner") and result["winner"] != tid:
                    # Check if this team was in this game
                    pass  # handled below

        teams[str(tid)] = {
            "name": seed_row["TeamName"],
            "seed": seed_row["Seed"],
            "seed_num": int(seed_row["SeedNum"]),
            "region": seed_row["Region"],
            "theta_mean": round(theta_mean, 2),
            "theta_std": round(theta_std, 2),
            "eliminated": eliminated,
            "eliminated_round": eliminated_round,
        }

    # Build advancement section
    advancement_dict = {}
    round_names = [
        "Round of 64", "Round of 32", "Sweet 16",
        "Elite Eight", "Final Four", "Championship", "Champion",
    ]
    for _, row in advancement.iterrows():
        tid = str(int(row["TeamID"]))
        advancement_dict[tid] = {
            rn: round(float(row[rn]), 4) for rn in round_names
        }

    # Build bracket section with win probabilities
    bracket = {}
    regular_slots = bracket_struct["regular_slots"]
    seed_to_team = bracket_struct["seed_to_team"]

    # Pre-compute most likely winners from simulation
    slot_winner_counts = {}
    for result in sim_results["all_results"]:
        for slot, winner in result["slot_winners"].items():
            if slot not in slot_winner_counts:
                slot_winner_counts[slot] = {}
            wname = winner["team_name"]
            slot_winner_counts[slot][wname] = slot_winner_counts[slot].get(wname, 0) + 1

    # For each slot, compute p_a_wins from simulation results
    def _resolve_seed(name, resolved_map):
        if name in seed_to_team:
            return seed_to_team[name]
        return resolved_map.get(name)

    # Build bracket entries for R1 games (seeds are known)
    for slot, (strong, weak) in sorted(regular_slots.items()):
        if not slot.startswith("R1"):
            continue
        team_a = seed_to_team.get(strong)
        team_b = seed_to_team.get(weak)
        if not team_a or not team_b:
            continue

        # Compute p_a_wins from sims
        n_sims = sim_results["n_sims"]
        counts = slot_winner_counts.get(slot, {})
        a_wins = counts.get(team_a["team_name"], 0)
        p_a = round(a_wins / n_sims, 4) if n_sims > 0 else 0.5

        result_val = actual_results.get(slot)

        bracket[slot] = {
            "team_a": {"id": team_a["team_id"], "name": team_a["team_name"], "seed_num": team_a["seed_num"]},
            "team_b": {"id": team_b["team_id"], "name": team_b["team_name"], "seed_num": team_b["seed_num"]},
            "p_a_wins": p_a,
            "result": result_val,
        }

    # For later-round games, identify the most likely teams and their probabilities
    for slot, (strong, weak) in sorted(regular_slots.items()):
        if slot.startswith("R1"):
            continue
        counts = slot_winner_counts.get(slot, {})
        if not counts:
            continue

        # The two most common teams in this slot across simulations
        # (these are the "expected" matchup participants)
        n_sims = sim_results["n_sims"]
        # Get the team that appeared most from each feeder slot
        # For simplicity, store the most likely winner and probability
        sorted_teams = sorted(counts.items(), key=lambda x: -x[1])
        winner_name = sorted_teams[0][0]
        p_winner = round(sorted_teams[0][1] / n_sims, 4)

        result_val = actual_results.get(slot)

        bracket[slot] = {
            "most_likely_winner": winner_name,
            "p_winner": p_winner,
            "all_probabilities": {name: round(c / n_sims, 4) for name, c in sorted_teams[:4]},
            "result": result_val,
        }

    # Championship odds
    champ_odds = {}
    for _, row in advancement.iterrows():
        champ_odds[str(int(row["TeamID"]))] = round(float(row["Champion"]), 4)

    # Hyperparameters (from posterior means/stds)
    hyper = {
        "sigma_mean": round(float(sigma_samples.mean()), 2),
        "sigma_std": round(float(sigma_samples.std()), 2),
    }

    snapshot = {
        "date": date,
        "games_in_training_data": int(data["n_games"]),
        "tournament_games_played": len([r for r in actual_results.values() if r]),
        "model_fit_timestamp": datetime.now(timezone.utc).isoformat(),
        "teams": teams,
        "advancement": advancement_dict,
        "bracket": bracket,
        "championship_odds": champ_odds,
        "hyperparameters": hyper,
        "actual_results": actual_results,
    }

    output_dir = Path(output_dir)
    snapshots_dir = output_dir / "snapshots"
    snapshots_dir.mkdir(parents=True, exist_ok=True)

    filepath = snapshots_dir / f"{date}.json"
    with open(filepath, "w") as f:
        json.dump(snapshot, f, separators=(",", ":"))

    # Also write as latest.json
    latest_path = output_dir / "latest.json"
    with open(latest_path, "w") as f:
        json.dump(snapshot, f, separators=(",", ":"))

    return filepath
  • Step 2: Create export_odds_timeline() function
def export_odds_timeline(snapshots_dir: Path, output_path: Path) -> None:
    """Read all snapshot files and produce a condensed timeline."""
    snapshots_dir = Path(snapshots_dir)
    timeline = {}  # team_id -> {date: odds}
    dates = []

    for snapshot_file in sorted(snapshots_dir.glob("*.json")):
        with open(snapshot_file) as f:
            snap = json.load(f)
        date = snap["date"]
        dates.append(date)
        for tid, odds in snap.get("championship_odds", {}).items():
            if tid not in timeline:
                timeline[tid] = {}
            timeline[tid][date] = odds

    # Include team names
    team_names = {}
    if dates:
        latest_file = snapshots_dir / f"{dates[-1]}.json"
        with open(latest_file) as f:
            snap = json.load(f)
        for tid, info in snap.get("teams", {}).items():
            team_names[tid] = info["name"]

    result = {
        "dates": dates,
        "teams": {
            tid: {"name": team_names.get(tid, tid), "odds": odds_by_date}
            for tid, odds_by_date in timeline.items()
        },
    }

    output_path = Path(output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    with open(output_path, "w") as f:
        json.dump(result, f, separators=(",", ":"))
  • Step 3: Create export_team_branding() function
def export_team_branding(
    men_seeds_df: pd.DataFrame,
    women_seeds_df: pd.DataFrame,
    output_path: Path,
) -> None:
    """Build team_branding.json with ESPN logo URLs and school colors.

    Fetches ESPN team data via their public API and maps to Kaggle team IDs
    using fuzzy name matching. Falls back to manual overrides for mismatches.
    """
    import urllib.request
    import urllib.parse

    # Collect all tournament teams
    all_teams = {}
    for _, row in pd.concat([men_seeds_df, women_seeds_df]).iterrows():
        tid = int(row["TeamID"])
        if tid not in all_teams:
            all_teams[tid] = row["TeamName"]

    # ESPN team lookup — fetch team list
    branding = {}
    espn_teams = {}

    for gender_path in ["mens-college-basketball", "womens-college-basketball"]:
        url = f"https://site.api.espn.com/apis/site/v2/sports/basketball/{gender_path}/teams?limit=500"
        try:
            req = urllib.request.Request(url)
            with urllib.request.urlopen(req, timeout=30) as resp:
                data = json.loads(resp.read().decode())
            for entry in data.get("sports", [{}])[0].get("leagues", [{}])[0].get("teams", []):
                team = entry.get("team", {})
                espn_id = int(team.get("id", 0))
                name = team.get("displayName", "")
                short = team.get("shortDisplayName", "")
                abbr = team.get("abbreviation", "")
                color = team.get("color", "")
                alt_color = team.get("alternateColor", "")
                espn_teams[name.lower()] = {
                    "espn_id": espn_id,
                    "name": name,
                    "color": f"#{color}" if color and not color.startswith("#") else color,
                    "alt_color": f"#{alt_color}" if alt_color and not alt_color.startswith("#") else alt_color,
                }
                espn_teams[short.lower()] = espn_teams[name.lower()]
                espn_teams[abbr.lower()] = espn_teams[name.lower()]
        except Exception as e:
            print(f"Warning: Could not fetch ESPN teams for {gender_path}: {e}")

    # Manual overrides for common mismatches
    name_overrides = {
        "St John's": "st. john's red storm",
        "St Mary's CA": "saint mary's gaels",
        "St Louis": "saint louis billikens",
        "Miami FL": "miami hurricanes",
        "Miami OH": "miami (oh) redhawks",
        "LIU Brooklyn": "long island university sharks",
        "N Dakota St": "north dakota state bison",
        "Cal Baptist": "california baptist lancers",
        "Queens NC": "queens royals",
        "High Point": "high point panthers",
        "Prairie View": "prairie view a&m panthers",
        "NC State": "nc state wolfpack",
        "South Florida": "south florida bulls",
        "Texas A&M": "texas a&m aggies",
        "McNeese St": "mcneese cowboys",
        "Tennessee St": "tennessee state tigers",
        "Utah St": "utah state aggies",
        "Michigan St": "michigan state spartans",
        "Iowa St": "iowa state cyclones",
        "Texas Tech": "texas tech red raiders",
        "Ohio St": "ohio state buckeyes",
        "Notre Dame": "notre dame fighting irish",
        "West Virginia": "west virginia mountaineers",
    }

    for tid, name in all_teams.items():
        override_key = name_overrides.get(name, "").lower()
        match = espn_teams.get(override_key) or espn_teams.get(name.lower()) or espn_teams.get(f"{name.lower()} ")

        # Try fuzzy: "Duke" -> "duke blue devils"
        if not match:
            for espn_name, espn_data in espn_teams.items():
                if name.lower() in espn_name:
                    match = espn_data
                    break

        if match:
            branding[str(tid)] = {
                "name": name,
                "espn_id": match["espn_id"],
                "logo_url": f"https://a.espncdn.com/i/teamlogos/ncaa/500-dark/{match['espn_id']}.png",
                "primary_color": match["color"],
                "secondary_color": match["alt_color"],
            }
        else:
            # Fallback: no ESPN match
            branding[str(tid)] = {
                "name": name,
                "espn_id": None,
                "logo_url": None,
                "primary_color": "#666666",
                "secondary_color": "#999999",
            }
            print(f"Warning: No ESPN match for {name} (ID {tid})")

    output_path = Path(output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    with open(output_path, "w") as f:
        json.dump(branding, f, indent=2)
    print(f"Exported branding for {len(branding)} teams to {output_path}")
  • Step 4: Create generate_baseline() convenience function
def generate_baseline(
    output_base: Path,
    date: str = "2026-03-16",
) -> None:
    """Generate all baseline data files from existing model results.

    Loads saved model artifacts and produces the initial snapshot,
    odds timeline, and team branding for both genders.
    """
    import arviz as az
    from src.data import build_model_data, load_seeds
    from src.simulate import build_bracket_structure, simulate_tournament

    output_base = Path(output_base)

    for gender in ["M", "W"]:
        suffix = "mens" if gender == "M" else "womens"
        gender_dir = output_base / suffix

        print(f"\nGenerating baseline for {suffix}...")
        data = build_model_data(2026, gender)
        idata = az.from_netcdf(f"results/model_2026_{suffix}.nc")

        theta = idata.posterior["theta"].values.reshape(-1, data["n_teams"])
        sigma = idata.posterior["sigma"].values.flatten()
        alpha = idata.posterior["alpha"].values.flatten()

        bracket_struct = build_bracket_structure(2026, gender)
        sim = simulate_tournament(
            bracket_struct, theta, sigma, data["team_ids"],
            n_sims=10000, seed=42,
            alpha_samples=alpha if gender == "W" else None,
        )

        export_snapshot(
            data=data,
            theta_samples=theta,
            sigma_samples=sigma,
            sim_results=sim,
            bracket_struct=bracket_struct,
            date=date,
            output_dir=gender_dir,
        )
        print(f"  Snapshot written to {gender_dir}/snapshots/{date}.json")

        export_odds_timeline(
            gender_dir / "snapshots",
            gender_dir / "odds_timeline.json",
        )

        del idata
        import gc; gc.collect()

    # Team branding
    men_seeds = load_seeds(2026, "M")
    women_seeds = load_seeds(2026, "W")
    export_team_branding(men_seeds, women_seeds, output_base / "team_branding.json")

    print(f"\nBaseline generation complete. Files in {output_base}")
  • Step 5: Run the baseline export and verify output

Run: cd /Users/tburch/Developer/march_madness && uv run python -c "from src.export import generate_baseline; from pathlib import Path; generate_baseline(Path('/Users/tburch/Developer/tjburch.github.io/assets/data/march-madness-2026'))"

Expected: JSON files created in assets/data/march-madness-2026/{mens,womens}/

  • Step 6: Verify snapshot size and schema

Run: ls -la assets/data/march-madness-2026/mens/snapshots/ && wc -c assets/data/march-madness-2026/mens/snapshots/2026-03-16.json

Expected: File under 50 KB, valid JSON with teams, advancement, bracket, championship_odds keys.


Task 2: Dashboard Jekyll Page

Files:

  • Create: /Users/tburch/Developer/tjburch.github.io/_posts/2026-03-16-march-madness-dashboard.md

  • Step 1: Create the Jekyll page with dashboard HTML structure

Create the post with front matter, intro text, and all dashboard container divs. Include Plotly.js CDN and the dashboard JS file. Use the single layout with classes: wide for maximum content width.

Key sections:

  • Gender toggle (Men’s / Women’s)
  • Date selector
  • Championship odds bar chart
  • Advancement probability heatmap
  • Region bracket views (tabbed)
  • Team deep dive panel (hidden by default, shown on team click)
  • Predictions vs reality section

Task 3: Dashboard JavaScript (march-madness-dashboard.js)

Files:

  • Create: /Users/tburch/Developer/tjburch.github.io/assets/js/march-madness-dashboard.js

This is the largest task — the JS that loads JSON, renders all Plotly charts, and handles interactivity.

  • Step 1: Data loading and state management

The JS module manages:

  • Current gender (M/W) and date selection
  • Loaded snapshot data and branding data
  • URL query string sync (?gender=M&date=2026-03-16)

  • Step 2: Championship odds bar chart

Horizontal bar chart with team colors from branding, top 20 teams, hover for exact percentages. Eliminated teams shown grayed out.

  • Step 3: Advancement probability heatmap

Heatmap (teams × rounds) for top 40 teams. Color scale white → deep red. Cell annotations with percentages.

  • Step 4: Region bracket tables

Four region views (tabbed) showing bracket matchups with win probabilities. Color-coded by probability. Actual results overlaid for completed games.

  • Step 5: Team deep dive panel

Click team name → shows posterior strength info, round-by-round odds, and odds-over-time line chart (from odds_timeline.json).

  • Step 6: Predictions vs reality section

Shows model prediction accuracy for completed games. Running accuracy tracker and biggest surprises.

  • Step 7: Gender toggle and date selector

Segmented button toggle for M/W. Date dropdown populated from available snapshots. URL state sync.


Task 4: Dashboard Styles

Files:

  • Modify: /Users/tburch/Developer/tjburch.github.io/_sass/custom.scss

  • Step 1: Add dashboard-specific SCSS

Append styles for the dashboard components: gender toggle, date selector, bracket tables, team deep dive panel. Must integrate with existing dark theme variables.


Task 5: GitHub Actions Pipeline

Files:

  • Create: /Users/tburch/Developer/march_madness/.github/workflows/daily-update.yml

  • Step 1: Create the workflow file

Daily cron (10 AM UTC) with manual trigger. Active window Mar 17 - Apr 7 2026. Steps: checkout, setup python+uv, fetch scores (ESPN API), fit model (both genders), simulate, export snapshots, push to site repo.


Task 6: Integration and Polish

  • Step 1: Test locally with Jekyll dev server

Run: bundle exec jekyll serve and verify the dashboard loads at http://localhost:4000/march-madness-2026/

  • Step 2: Verify all charts render with baseline data

  • Step 3: Test gender toggle and date selector

  • Step 4: Test team click interaction

  • Step 5: Verify mobile responsiveness