feat: add Django web app, CloudKit sync, dashboard, and game_datetime_utc export

Adds the full Django application layer on top of sportstime_parser:
- core: Sport, Team, Stadium, Game models with aliases and league structure
- scraper: orchestration engine, adapter, job management, Celery tasks
- cloudkit: CloudKit sync client, sync state tracking, sync jobs
- dashboard: staff dashboard for monitoring scrapers, sync, review queue
- notifications: email reports for scrape/sync results
- Docker setup for deployment (Dockerfile, docker-compose, entrypoint)

Game exports now use game_datetime_utc (ISO 8601 UTC) instead of
venue-local date+time strings, matching the canonical format used
by the iOS app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-19 14:04:27 -06:00
parent 4353d5943c
commit 63acf7accb
114 changed files with 13070 additions and 887 deletions

View File

@@ -0,0 +1,130 @@
{% extends "admin/base_site.html" %}
{% load static %}
{% block extrahead %}
{{ block.super }}
<style>
.dashboard-container {
padding: 20px;
}
.stat-card {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.stat-card h3 {
margin: 0 0 15px 0;
color: #417690;
font-size: 16px;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.stat-item {
text-align: center;
padding: 15px;
background: #f8f9fa;
border-radius: 6px;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #417690;
}
.stat-label {
font-size: 12px;
color: #666;
text-transform: uppercase;
margin-top: 5px;
}
.table-responsive {
overflow-x: auto;
}
table.dashboard-table {
width: 100%;
border-collapse: collapse;
}
table.dashboard-table th,
table.dashboard-table td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #eee;
}
table.dashboard-table th {
background: #f8f9fa;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
color: #666;
}
.status-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: bold;
color: white;
}
.status-completed, .status-synced { background: #5cb85c; }
.status-running, .status-pending { background: #f0ad4e; }
.status-failed { background: #d9534f; }
.status-cancelled { background: #777; }
.btn-action {
display: inline-block;
padding: 8px 16px;
background: #417690;
color: white;
text-decoration: none;
border-radius: 4px;
font-size: 13px;
border: none;
cursor: pointer;
}
.btn-action:hover {
background: #205067;
color: white;
}
.btn-action.btn-secondary {
background: #6c757d;
}
.btn-action.btn-danger {
background: #d9534f;
}
.nav-tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #eee;
padding-bottom: 10px;
}
.nav-tabs a {
padding: 8px 16px;
text-decoration: none;
color: #666;
border-radius: 4px;
}
.nav-tabs a.active, .nav-tabs a:hover {
background: #417690;
color: white;
}
</style>
{% endblock %}
{% block content %}
<div class="dashboard-container">
<div class="nav-tabs">
<a href="{% url 'dashboard:index' %}" {% if request.resolver_match.url_name == 'index' %}class="active"{% endif %}>Overview</a>
<a href="{% url 'dashboard:stats' %}" {% if request.resolver_match.url_name == 'stats' %}class="active"{% endif %}>Statistics</a>
<a href="{% url 'dashboard:scraper_status' %}" {% if request.resolver_match.url_name == 'scraper_status' %}class="active"{% endif %}>Scrapers</a>
<a href="{% url 'dashboard:sync_status' %}" {% if request.resolver_match.url_name == 'sync_status' %}class="active"{% endif %}>CloudKit Sync</a>
<a href="{% url 'dashboard:review_queue' %}" {% if request.resolver_match.url_name == 'review_queue' %}class="active"{% endif %}>Review Queue{% if pending_reviews %} ({{ pending_reviews }}){% endif %}</a>
</div>
{% block dashboard_content %}{% endblock %}
</div>
{% endblock %}

View File

@@ -0,0 +1,125 @@
{% extends "dashboard/base.html" %}
{% block dashboard_content %}
<div class="stat-card">
<h3>Overview</h3>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-value">{{ sports_count }}</div>
<div class="stat-label">Sports</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ teams_count }}</div>
<div class="stat-label">Teams</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stadiums_count }}</div>
<div class="stat-label">Stadiums</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ games_count }}</div>
<div class="stat-label">Games</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ pending_reviews }}</div>
<div class="stat-label">Pending Reviews</div>
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div class="stat-card">
<h3>Recent Scraper Jobs</h3>
{% if recent_jobs %}
<div class="table-responsive">
<table class="dashboard-table">
<thead>
<tr>
<th>Sport</th>
<th>Status</th>
<th>Games</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for job in recent_jobs %}
<tr>
<td>{{ job.config.sport.short_name }} {{ job.config.season }}</td>
<td><span class="status-badge status-{{ job.status }}">{{ job.status|upper }}</span></td>
<td>{{ job.games_found }}</td>
<td>{{ job.created_at|timesince }} ago</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No recent scraper jobs.</p>
{% endif %}
<p style="margin-top: 15px;">
<a href="{% url 'dashboard:scraper_status' %}" class="btn-action">View All Jobs</a>
</p>
</div>
<div class="stat-card">
<h3>Recent CloudKit Syncs</h3>
{% if recent_syncs %}
<div class="table-responsive">
<table class="dashboard-table">
<thead>
<tr>
<th>Config</th>
<th>Status</th>
<th>Records</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for sync in recent_syncs %}
<tr>
<td>{{ sync.configuration.name }}</td>
<td><span class="status-badge status-{{ sync.status }}">{{ sync.status|upper }}</span></td>
<td>{{ sync.records_synced }}</td>
<td>{{ sync.created_at|timesince }} ago</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No recent sync jobs.</p>
{% endif %}
<p style="margin-top: 15px;">
<a href="{% url 'dashboard:sync_status' %}" class="btn-action">View Sync Status</a>
</p>
</div>
</div>
<div class="stat-card">
<h3>Sport Summary</h3>
<div class="table-responsive">
<table class="dashboard-table">
<thead>
<tr>
<th>Sport</th>
<th>Teams</th>
<th>Stadiums</th>
<th>Games</th>
<th>Pending Reviews</th>
</tr>
</thead>
<tbody>
{% for stat in sport_stats %}
<tr>
<td><strong>{{ stat.sport.short_name }}</strong> - {{ stat.sport.name }}</td>
<td>{{ stat.teams }}</td>
<td>{{ stat.stadiums }}</td>
<td>{{ stat.games }}</td>
<td>{% if stat.pending_reviews %}<span style="color: #f0ad4e; font-weight: bold;">{{ stat.pending_reviews }}</span>{% else %}0{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,74 @@
{% extends "dashboard/base.html" %}
{% block dashboard_content %}
<div class="stat-card">
<h3>Review Queue Summary</h3>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-value">{{ total_pending }}</div>
<div class="stat-label">Total Pending</div>
</div>
{% for item in review_summary %}
<div class="stat-item">
<div class="stat-value">{{ item.count }}</div>
<div class="stat-label">{{ item.sport__short_name }} {{ item.item_type }}s</div>
</div>
{% endfor %}
</div>
</div>
<div class="stat-card">
<h3>Pending Review Items</h3>
{% if pending_items %}
<div class="table-responsive">
<table class="dashboard-table">
<thead>
<tr>
<th>Type</th>
<th>Sport</th>
<th>Raw Value</th>
<th>Suggested Match</th>
<th>Confidence</th>
<th>Reason</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in pending_items %}
<tr>
<td>{{ item.item_type }}</td>
<td>{{ item.sport.short_name }}</td>
<td><code>{{ item.raw_value }}</code></td>
<td>
{% if item.suggested_id %}
<code style="background: #e8f5e9;">{{ item.suggested_id }}</code>
{% else %}
<span style="color: #999;">None</span>
{% endif %}
</td>
<td>
{% if item.confidence > 0 %}
<span style="color: {% if item.confidence >= 0.85 %}#5cb85c{% elif item.confidence >= 0.7 %}#f0ad4e{% else %}#d9534f{% endif %}; font-weight: bold;">
{{ item.confidence_display }}
</span>
{% else %}-{% endif %}
</td>
<td>{{ item.get_reason_display }}</td>
<td>
<a href="{% url 'admin:scraper_manualreviewitem_change' item.id %}" class="btn-action">Review</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pending > 50 %}
<p style="margin-top: 15px; color: #666;">
Showing 50 of {{ total_pending }} items. <a href="{% url 'admin:scraper_manualreviewitem_changelist' %}?status__exact=pending">View all in admin</a>.
</p>
{% endif %}
{% else %}
<p style="color: #5cb85c; font-weight: bold;">No pending review items! 🎉</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,100 @@
{% extends "dashboard/base.html" %}
{% block dashboard_content %}
<div class="stat-card">
<h3>Scraper Status</h3>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-value">{{ running_jobs }}</div>
<div class="stat-label">Running</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ pending_jobs }}</div>
<div class="stat-label">Pending</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ configs.count }}</div>
<div class="stat-label">Configurations</div>
</div>
</div>
</div>
<div class="stat-card">
<h3>Scraper Configurations</h3>
<div class="table-responsive">
<table class="dashboard-table">
<thead>
<tr>
<th>Sport</th>
<th>Season</th>
<th>Enabled</th>
<th>Last Run</th>
<th>Status</th>
<th>Games</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for config in configs %}
<tr>
<td><strong>{{ config.sport.short_name }}</strong></td>
<td>{{ config.sport.get_season_display }}</td>
<td>{% if config.is_enabled %}<span style="color: green;"></span>{% else %}<span style="color: #999;"></span>{% endif %}</td>
<td>{% if config.last_run %}{{ config.last_run|timesince }} ago{% else %}-{% endif %}</td>
<td>
{% if config.last_run_status %}
<span class="status-badge status-{{ config.last_run_status }}">{{ config.last_run_status|upper }}</span>
{% else %}-{% endif %}
</td>
<td>{{ config.last_run_games }}</td>
<td>
<form method="post" action="{% url 'dashboard:run_scraper' config.sport.code config.season %}" style="display: inline;">
{% csrf_token %}
<button type="submit" class="btn-action" {% if not config.is_enabled %}disabled{% endif %}>Run Now</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="stat-card">
<h3>Recent Jobs</h3>
<div class="table-responsive">
<table class="dashboard-table">
<thead>
<tr>
<th>ID</th>
<th>Sport</th>
<th>Status</th>
<th>Trigger</th>
<th>Started</th>
<th>Duration</th>
<th>Games</th>
<th>Reviews</th>
</tr>
</thead>
<tbody>
{% for job in recent_jobs %}
<tr>
<td><a href="{% url 'admin:scraper_scrapejob_change' job.id %}">{{ job.id }}</a></td>
<td>{{ job.config.sport.short_name }} {{ job.config.season }}</td>
<td><span class="status-badge status-{{ job.status }}">{{ job.status|upper }}</span></td>
<td>{{ job.triggered_by }}</td>
<td>{% if job.started_at %}{{ job.started_at|timesince }} ago{% else %}-{% endif %}</td>
<td>{{ job.duration_display }}</td>
<td>
{% if job.games_found %}
{{ job.games_found }} ({{ job.games_new }} new, {{ job.games_updated }} upd)
{% else %}-{% endif %}
</td>
<td>{% if job.review_items_created %}{{ job.review_items_created }}{% else %}-{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,85 @@
{% extends "dashboard/base.html" %}
{% block dashboard_content %}
<div class="stat-card">
<h3>Game Statistics</h3>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-value">{{ game_stats.total }}</div>
<div class="stat-label">Total Games</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ game_stats.scheduled }}</div>
<div class="stat-label">Scheduled</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ game_stats.final }}</div>
<div class="stat-label">Final</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ game_stats.today }}</div>
<div class="stat-label">Today</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ game_stats.this_week }}</div>
<div class="stat-label">This Week</div>
</div>
</div>
</div>
<div class="stat-card">
<h3>CloudKit Sync Statistics</h3>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-value">{{ sync_stats.total }}</div>
<div class="stat-label">Total Records</div>
</div>
<div class="stat-item">
<div class="stat-value" style="color: #5cb85c;">{{ sync_stats.synced }}</div>
<div class="stat-label">Synced</div>
</div>
<div class="stat-item">
<div class="stat-value" style="color: #f0ad4e;">{{ sync_stats.pending }}</div>
<div class="stat-label">Pending</div>
</div>
<div class="stat-item">
<div class="stat-value" style="color: #d9534f;">{{ sync_stats.failed }}</div>
<div class="stat-label">Failed</div>
</div>
</div>
</div>
<div class="stat-card">
<h3>Data by Sport</h3>
<div class="table-responsive">
<table class="dashboard-table">
<thead>
<tr>
<th>Sport</th>
<th>Teams</th>
<th>Stadiums</th>
<th>Games</th>
<th>Pending Reviews</th>
</tr>
</thead>
<tbody>
{% for stat in sport_stats %}
<tr>
<td><strong>{{ stat.sport.short_name }}</strong> - {{ stat.sport.name }}</td>
<td>{{ stat.teams }}</td>
<td>{{ stat.stadiums }}</td>
<td>{{ stat.games }}</td>
<td>
{% if stat.pending_reviews %}
<span style="color: #f0ad4e; font-weight: bold;">{{ stat.pending_reviews }}</span>
{% else %}
<span style="color: #5cb85c;">0</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,382 @@
{% extends 'base.html' %}
{% block content %}
<h1>CloudKit Sync</h1>
<!-- Status Overview -->
<div class="stat-grid mb-2">
<div class="stat-card {% if running_syncs > 0 %}primary{% endif %}">
<div class="stat-value">{{ running_syncs|default:0 }}</div>
<div class="stat-label">Running Syncs</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ total_records }}</div>
<div class="stat-label">Total Records</div>
</div>
</div>
<!-- CloudKit Configurations -->
<div class="card">
<div class="card-header">
<h3 style="margin: 0;">CloudKit Configurations</h3>
</div>
{% if all_configs %}
<table class="table">
<thead>
<tr>
<th>Config</th>
<th>Environment</th>
<th>Container</th>
<th>Status</th>
<th>Progress</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for c in all_configs %}
<tr id="config-row-{{ c.id }}">
<td><strong>{{ c.name }}</strong>{% if c.is_active %} ★{% endif %}</td>
<td>
{% if c.environment == 'production' %}
<span class="badge badge-success">Production</span>
{% else %}
<span class="badge badge-info">Development</span>
{% endif %}
</td>
<td>{{ c.container_id }}</td>
<td>
{% if c.is_active %}
<span class="badge badge-success">Active</span>
{% else %}
<span class="badge badge-secondary">Inactive</span>
{% endif %}
</td>
<td id="progress-{{ c.id }}" style="min-width: 200px;">
<span class="text-muted">-</span>
</td>
<td>
<button type="button" class="btn btn-primary sync-btn" data-config-id="{{ c.id }}" data-config-name="{{ c.name }}" data-environment="{{ c.environment }}" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Sync Now</button>
<a href="{% url 'admin:cloudkit_cloudkitconfiguration_change' c.id %}" class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="padding: 1rem;">
<p class="text-muted">No CloudKit configuration found. <a href="{% url 'admin:cloudkit_cloudkitconfiguration_add' %}">Create one</a>.</p>
</div>
{% endif %}
</div>
<!-- Confirmation Modal -->
<div id="sync-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000;">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 2rem; border-radius: 8px; max-width: 440px; width: 90%;">
<h3 style="margin-top: 0;">Confirm Sync</h3>
<p>Sync to <strong id="modal-config-name"></strong> (<span id="modal-environment"></span>)</p>
<form id="sync-form" method="post" action="{% url 'dashboard:run_sync' %}">
{% csrf_token %}
<input type="hidden" name="config_id" id="modal-config-id">
<div style="margin-bottom: 1rem;">
<label style="font-weight: 600; display: block; margin-bottom: 0.5rem;">Record Types</label>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem 1rem;">
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="all" id="cb-all" checked onchange="toggleAll(this)"> <strong>Sync All</strong>
</label>
<div></div>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="Sport" class="cb-type" onchange="uncheckAll()"> Sport
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="Conference" class="cb-type" onchange="uncheckAll()"> Conference
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="Division" class="cb-type" onchange="uncheckAll()"> Division
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="Team" class="cb-type" onchange="uncheckAll()"> Team
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="Stadium" class="cb-type" onchange="uncheckAll()"> Stadium
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="Game" class="cb-type" onchange="uncheckAll()"> Game
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="TeamAlias" class="cb-type" onchange="uncheckAll()"> TeamAlias
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="StadiumAlias" class="cb-type" onchange="uncheckAll()"> StadiumAlias
</label>
<label style="font-weight: normal; cursor: pointer;">
<input type="checkbox" name="record_types" value="LeagueStructure" class="cb-type" onchange="uncheckAll()"> LeagueStructure
</label>
</div>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1.5rem;">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Sync Now</button>
</div>
</form>
</div>
</div>
<!-- Recent Sync Jobs -->
<div class="card">
<div class="card-header">
<h3 style="margin: 0;">Recent Sync Jobs</h3>
</div>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Config</th>
<th>Status</th>
<th>Type</th>
<th>Trigger</th>
<th>Started</th>
<th>Duration</th>
<th>Records</th>
</tr>
</thead>
<tbody>
{% for sync in recent_syncs %}
<tr data-job-id="{{ sync.id }}">
<td><a href="{% url 'admin:cloudkit_cloudkitsyncjob_change' sync.id %}">{{ sync.id }}</a></td>
<td>
{{ sync.configuration.name }}
{% if sync.configuration.environment == 'production' %}
<span class="badge badge-success" style="font-size: 0.7rem;">Prod</span>
{% else %}
<span class="badge badge-info" style="font-size: 0.7rem;">Dev</span>
{% endif %}
</td>
<td>
{% if sync.status == 'completed' %}
<span class="badge badge-success">Completed</span>
{% elif sync.status == 'running' %}
<span class="badge badge-info">Running</span>
{% elif sync.status == 'failed' %}
<span class="badge badge-danger">Failed</span>
{% elif sync.status == 'cancelled' %}
<span class="badge badge-secondary">Cancelled</span>
{% else %}
<span class="badge badge-warning">{{ sync.status|title }}</span>
{% endif %}
</td>
<td>
{% if sync.record_type_filter %}
<span class="badge badge-info">{{ sync.record_type_filter }}</span>
{% else %}
All
{% endif %}
</td>
<td>{{ sync.triggered_by }}</td>
<td class="text-muted">{% if sync.started_at %}{{ sync.started_at|timesince }} ago{% else %}-{% endif %}</td>
<td>{{ sync.duration_display }}</td>
<td>
{% if sync.records_synced or sync.records_failed %}
{{ sync.records_synced }} synced{% if sync.records_failed %}, <span class="text-danger">{{ sync.records_failed }} failed</span>{% endif %}
<div style="font-size: 0.78rem; color: #666; margin-top: 0.25rem; line-height: 1.5;">
{% if sync.sports_synced or sync.sports_failed %}
<span>Sport: {{ sync.sports_synced }}{% if sync.sports_failed %}<span class="text-danger">/{{ sync.sports_failed }}f</span>{% endif %}</span><br>
{% endif %}
{% if sync.conferences_synced or sync.conferences_failed %}
<span>Conf: {{ sync.conferences_synced }}{% if sync.conferences_failed %}<span class="text-danger">/{{ sync.conferences_failed }}f</span>{% endif %}</span><br>
{% endif %}
{% if sync.divisions_synced or sync.divisions_failed %}
<span>Div: {{ sync.divisions_synced }}{% if sync.divisions_failed %}<span class="text-danger">/{{ sync.divisions_failed }}f</span>{% endif %}</span><br>
{% endif %}
{% if sync.teams_synced or sync.teams_failed %}
<span>Team: {{ sync.teams_synced }}{% if sync.teams_failed %}<span class="text-danger">/{{ sync.teams_failed }}f</span>{% endif %}</span><br>
{% endif %}
{% if sync.stadiums_synced or sync.stadiums_failed %}
<span>Stadium: {{ sync.stadiums_synced }}{% if sync.stadiums_failed %}<span class="text-danger">/{{ sync.stadiums_failed }}f</span>{% endif %}</span><br>
{% endif %}
{% if sync.games_synced or sync.games_failed %}
<span>Game: {{ sync.games_synced }}{% if sync.games_failed %}<span class="text-danger">/{{ sync.games_failed }}f</span>{% endif %}</span><br>
{% endif %}
{% if sync.team_aliases_synced or sync.team_aliases_failed %}
<span>TeamAlias: {{ sync.team_aliases_synced }}{% if sync.team_aliases_failed %}<span class="text-danger">/{{ sync.team_aliases_failed }}f</span>{% endif %}</span><br>
{% endif %}
{% if sync.stadium_aliases_synced or sync.stadium_aliases_failed %}
<span>StadiumAlias: {{ sync.stadium_aliases_synced }}{% if sync.stadium_aliases_failed %}<span class="text-danger">/{{ sync.stadium_aliases_failed }}f</span>{% endif %}</span><br>
{% endif %}
</div>
{% else %}-{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="8" class="text-muted">No sync jobs yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
let pollingIntervals = {};
let checkRunningInterval = null;
document.addEventListener('DOMContentLoaded', function() {
// Attach click handlers to sync buttons
document.querySelectorAll('.sync-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const configId = this.dataset.configId;
const configName = this.dataset.configName;
const environment = this.dataset.environment;
showModal(configId, configName, environment);
});
});
// Check for running syncs and start polling
{% for sync in recent_syncs %}
{% if sync.status == 'running' %}
startPolling({{ sync.id }}, {{ sync.configuration.id }});
{% endif %}
{% endfor %}
// Also check for running syncs after a delay (catches jobs started just before page load)
setTimeout(checkForRunningSyncs, 1000);
setTimeout(checkForRunningSyncs, 3000);
});
function checkForRunningSyncs() {
fetch('/dashboard/api/running-syncs/')
.then(response => response.json())
.then(data => {
data.running.forEach(function(job) {
// Only start polling if not already polling this config
if (!pollingIntervals[job.configuration_id]) {
startPolling(job.id, job.configuration_id);
}
});
})
.catch(error => console.error('Error checking running syncs:', error));
}
function showModal(configId, configName, environment) {
document.getElementById('modal-config-id').value = configId;
document.getElementById('modal-config-name').textContent = configName;
document.getElementById('modal-environment').textContent = environment;
// Reset to "Sync All" checked, individual unchecked
document.getElementById('cb-all').checked = true;
document.querySelectorAll('.cb-type').forEach(function(cb) { cb.checked = false; });
document.getElementById('sync-modal').style.display = 'block';
}
function closeModal() {
document.getElementById('sync-modal').style.display = 'none';
}
function toggleAll(allCb) {
if (allCb.checked) {
document.querySelectorAll('.cb-type').forEach(function(cb) { cb.checked = false; });
}
}
function uncheckAll() {
var anyChecked = document.querySelectorAll('.cb-type:checked').length > 0;
document.getElementById('cb-all').checked = !anyChecked;
}
// Close modal on outside click
document.getElementById('sync-modal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
function startPolling(jobId, configId) {
const btn = document.querySelector('.sync-btn[data-config-id="' + configId + '"]');
if (btn) {
btn.disabled = true;
btn.textContent = 'Syncing...';
}
pollingIntervals[configId] = setInterval(function() {
fetchProgress(jobId, configId);
}, 1000);
fetchProgress(jobId, configId);
}
function stopPolling(configId) {
if (pollingIntervals[configId]) {
clearInterval(pollingIntervals[configId]);
delete pollingIntervals[configId];
}
const btn = document.querySelector('.sync-btn[data-config-id="' + configId + '"]');
if (btn) {
btn.disabled = false;
btn.textContent = 'Sync Now';
}
}
function fetchProgress(jobId, configId) {
fetch('/dashboard/api/sync-progress/' + jobId + '/')
.then(response => response.json())
.then(data => {
updateProgressColumn(configId, data);
if (data.status === 'completed' || data.status === 'failed') {
stopPolling(configId);
setTimeout(function() { location.reload(); }, 2000);
}
})
.catch(error => console.error('Error fetching progress:', error));
}
function updateProgressColumn(configId, data) {
const progressEl = document.getElementById('progress-' + configId);
if (!progressEl) return;
if (data.status === 'running') {
let html = '<div style="font-size: 0.85rem; line-height: 1.6;">';
// Teams
if (data.teams.total > 0) {
const teamsDone = data.teams.synced + data.teams.failed;
const teamsClass = teamsDone >= data.teams.total ? 'text-success' : (data.current_type === 'Team' ? 'text-primary' : 'text-muted');
html += '<div class="' + teamsClass + '"><strong>Teams:</strong> ' + teamsDone + '/' + data.teams.total;
if (data.teams.failed > 0) html += ' <span class="text-danger">(' + data.teams.failed + ' failed)</span>';
html += '</div>';
} else if (data.current_type === 'Team') {
html += '<div class="text-primary"><strong>Teams:</strong> starting...</div>';
}
// Stadiums
if (data.stadiums.total > 0) {
const stadiumsDone = data.stadiums.synced + data.stadiums.failed;
const stadiumsClass = stadiumsDone >= data.stadiums.total ? 'text-success' : (data.current_type === 'Stadium' ? 'text-primary' : 'text-muted');
html += '<div class="' + stadiumsClass + '"><strong>Stadiums:</strong> ' + stadiumsDone + '/' + data.stadiums.total;
if (data.stadiums.failed > 0) html += ' <span class="text-danger">(' + data.stadiums.failed + ' failed)</span>';
html += '</div>';
} else if (data.current_type === 'Stadium') {
html += '<div class="text-primary"><strong>Stadiums:</strong> starting...</div>';
}
// Games
if (data.games.total > 0) {
const gamesDone = data.games.synced + data.games.failed;
const gamesClass = gamesDone >= data.games.total ? 'text-success' : (data.current_type === 'Game' ? 'text-primary' : 'text-muted');
html += '<div class="' + gamesClass + '"><strong>Games:</strong> ' + gamesDone + '/' + data.games.total;
if (data.games.failed > 0) html += ' <span class="text-danger">(' + data.games.failed + ' failed)</span>';
html += '</div>';
} else if (data.current_type === 'Game') {
html += '<div class="text-primary"><strong>Games:</strong> starting...</div>';
}
html += '</div>';
progressEl.innerHTML = html;
} else if (data.status === 'completed') {
progressEl.innerHTML = '<span class="badge badge-success">Complete!</span> ' + data.synced + ' synced';
} else if (data.status === 'failed') {
progressEl.innerHTML = '<span class="badge badge-danger">Failed</span>';
}
}
</script>
{% endblock %}