add adming page and run rustfmt

This commit is contained in:
2026-05-03 22:27:32 -07:00
parent 9eb09f6565
commit 5881102cb6
7 changed files with 317 additions and 153 deletions

7
shell.nix Normal file
View File

@ -0,0 +1,7 @@
let
pkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/refs/tags/25.11.tar.gz") {};
in pkgs.mkShell {
packages = with pkgs; [
openssl pkg-config sqlite
];
}

View File

@ -1,5 +1,5 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Player {
pub username: String,
@ -18,7 +18,7 @@ pub struct Game {
pub code: String,
pub teams: Vec<Team>,
pub start_time: String,
pub end_time: String
pub end_time: String,
}
impl Team {
@ -35,5 +35,5 @@ impl Team {
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TeamLegend {
pub name: String,
pub color: String
pub color: String,
}

View File

@ -1,14 +1,16 @@
use actix_web::{web, Error, error};
use std::collections::HashMap;
use crate::data::{Game, Player, Team};
use actix_web::{Error, error, web};
use std::collections::HashMap;
pub type Pool = r2d2::Pool<r2d2_sqlite::SqliteConnectionManager>;
pub type Connection = r2d2::PooledConnection<r2d2_sqlite::SqliteConnectionManager>;
type GamesResult = Result<Vec<Game>, rusqlite::Error>;
type TeamsResult = Result<HashMap<String,Team>, rusqlite::Error>;
type TeamsResult = Result<HashMap<String, Team>, rusqlite::Error>;
const GAMES_QUERY: &str = "select code, group_concat(team), max(start_time), max(end_time) from games group by code";
const TEAMS_QUERY: &str = "select game, team, min(color), group_concat(player) from teams group by game, team";
const GAMES_QUERY: &str =
"select code, group_concat(team), max(start_time), max(end_time) from games group by code";
const TEAMS_QUERY: &str =
"select game, team, min(color), group_concat(player) from teams group by game, team";
pub async fn games(pool: &Pool) -> Result<Vec<Game>, Error> {
let pool = pool.clone();
@ -16,30 +18,26 @@ pub async fn games(pool: &Pool) -> Result<Vec<Game>, Error> {
let conn = web::block(move || pool.get())
.await?
.map_err(error::ErrorInternalServerError)?;
web::block(move || {
get_all_games(conn, teams_result)
})
.await?
.map_err(error::ErrorInternalServerError)
web::block(move || get_all_games(conn, teams_result))
.await?
.map_err(error::ErrorInternalServerError)
}
pub async fn teams(pool: &Pool) -> Result<HashMap<String,Team>, Error> {
pub async fn teams(pool: &Pool) -> Result<HashMap<String, Team>, Error> {
let pool = pool.clone();
let conn = web::block(move || pool.get())
.await?
.map_err(error::ErrorInternalServerError)?;
web::block(move || {
get_all_teams(conn)
})
.await?
.map_err(error::ErrorInternalServerError)
web::block(move || get_all_teams(conn))
.await?
.map_err(error::ErrorInternalServerError)
}
fn get_all_games(conn: Connection, all_teams: HashMap<String,Team>) -> GamesResult {
fn get_all_games(conn: Connection, all_teams: HashMap<String, Team>) -> GamesResult {
let mut games_stmt = conn.prepare(GAMES_QUERY)?;
games_stmt.query_map([], |games_row| {
Ok(
{
games_stmt
.query_map([], |games_row| {
Ok({
let game_name: String = games_row.get(0)?;
let teams: String = games_row.get(1)?;
let teams: Vec<Team> = teams
@ -47,39 +45,41 @@ fn get_all_games(conn: Connection, all_teams: HashMap<String,Team>) -> GamesResu
.map(|part| all_teams.get(&format!("{game_name}_{part}")).unwrap())
.map(|part| part.to_owned())
.collect();
Game{
Game {
code: game_name,
teams,
start_time: games_row.get(2)?,
end_time: games_row.get(3)?
end_time: games_row.get(3)?,
}
}
)
}).and_then(Iterator::collect)
})
})
.and_then(Iterator::collect)
}
fn get_all_teams(conn: Connection) -> TeamsResult {
let mut teams_stmt = conn.prepare(TEAMS_QUERY)?;
teams_stmt.query_map([], |teams_row|{
let game_name: String = teams_row.get(0)?;
let team_name: String = teams_row.get(1)?;
let color: String = teams_row.get(2)?;
let key = format!("{game_name}_{team_name}");
let players: String = teams_row.get(3)?;
let players: Vec<Player> = players
.split(',')
.map(|part| Player{username: part.to_string()})
.collect();
Ok(
(
teams_stmt
.query_map([], |teams_row| {
let game_name: String = teams_row.get(0)?;
let team_name: String = teams_row.get(1)?;
let color: String = teams_row.get(2)?;
let key = format!("{game_name}_{team_name}");
let players: String = teams_row.get(3)?;
let players: Vec<Player> = players
.split(',')
.map(|part| Player {
username: part.to_string(),
})
.collect();
Ok((
key,
Team{
Team {
name: team_name,
color,
players,
scores: HashMap::new()
}
)
)
}).and_then(Iterator::collect)
scores: HashMap::new(),
},
))
})
.and_then(Iterator::collect)
}

View File

@ -1,13 +1,12 @@
use crate::Team;
use geo as geo_types;
use std::convert::TryFrom;
use std::fs;
use geo::algorithm::contains::Contains;
use geojson::{FeatureCollection, GeoJson, Geometry, Value};
use crate::Team;
use std::convert::TryFrom;
use std::fs;
pub struct GeoUtils {
feature_collection: FeatureCollection
feature_collection: FeatureCollection,
}
impl GeoUtils {
@ -16,11 +15,14 @@ impl GeoUtils {
for feature in &mut fc.features {
feature.set_property("color", "#888888ff");
}
Self {feature_collection: fc}
Self {
feature_collection: fc,
}
}
fn load_feature_collection() -> FeatureCollection {
let txt: String = fs::read_to_string("Neighborhood_Map_Atlas_Neighborhoods.geojson").expect("file should be present");
let txt: String = fs::read_to_string("Neighborhood_Map_Atlas_Neighborhoods.geojson")
.expect("file should be present");
let geojson_str = txt;
let geojson: GeoJson = geojson_str.parse::<GeoJson>().unwrap();
FeatureCollection::try_from(geojson).unwrap()
@ -30,7 +32,7 @@ impl GeoUtils {
self.feature_collection.clone()
}
pub fn get_colored_collection_copy(self, teams: Vec<Team>) -> FeatureCollection {
pub fn get_colored_collection_copy(self, teams: Vec<Team>) -> FeatureCollection {
let mut copy = self.feature_collection.clone();
for feature in &mut copy.features {
let name = feature.property("S_HOOD").unwrap_or_default();
@ -38,7 +40,7 @@ impl GeoUtils {
let mut max_team: (Option<String>, i32) = (None, 0);
for team in &teams {
let score = team.scores.get(name).unwrap_or(&0);
if *score > max_team.1 {
if *score > max_team.1 {
max_team = (Some(team.color.clone()), *score);
} else if *score > 0 && *score == max_team.1 {
// Tie doesn't count
@ -61,7 +63,7 @@ impl GeoUtils {
let name = feature.property("S_HOOD").unwrap_or_default();
let name = name.as_str().unwrap_or("unknown");
let geometry: Geometry = feature.clone().geometry.expect("Feature has no geometry");
match geometry.value {
Value::Polygon(coords) => {
// Outer ring
@ -75,14 +77,17 @@ impl GeoUtils {
}
}
Value::MultiPolygon(coords) => {
let multipoly = coords.iter().map(|poly_coords| {
let exterior = poly_coords[0]
.iter()
.map(|coord| (coord[0], coord[1]))
.collect::<Vec<(f64, f64)>>()
.into();
geo_types::Polygon::new(exterior, vec![])
}).collect::<Vec<geo_types::Polygon<f64>>>();
let multipoly = coords
.iter()
.map(|poly_coords| {
let exterior = poly_coords[0]
.iter()
.map(|coord| (coord[0], coord[1]))
.collect::<Vec<(f64, f64)>>()
.into();
geo_types::Polygon::new(exterior, vec![])
})
.collect::<Vec<geo_types::Polygon<f64>>>();
let multipolygon = geo_types::MultiPolygon::new(multipoly);
if multipolygon.contains(&point) {
return String::from(name);
@ -94,4 +99,3 @@ impl GeoUtils {
String::from("Outside")
}
}

View File

@ -1,8 +1,11 @@
pub mod osm;
mod data;
mod geo_utils;
mod db;
mod geo_utils;
pub mod osm;
use crate::data::Game;
use crate::data::{Team, TeamLegend};
use crate::osm::scores;
use actix::ActorFutureExt;
use actix::Addr;
use actix::AtomicResponse;
@ -11,23 +14,21 @@ use actix::Message;
use actix::WrapFuture;
use actix::dev::MessageResponse;
use actix::dev::OneshotSender;
use r2d2_sqlite::SqliteConnectionManager;
use actix::prelude::Actor;
use actix_web::{App, HttpResponse, HttpServer, Responder, get, web};
use chrono::NaiveDateTime;
use db::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use reqwest::Client;
use std::error::Error;
use std::ops::Deref;
use std::sync::Arc;
use std::time::Duration;
use tera::Context;
use tera::Tera;
use tokio::sync::Mutex;
use crate::data::Game;
use crate::osm::scores;
use std::error::Error;
use std::sync::Arc;
use chrono::NaiveDateTime;
use reqwest::Client;
use xml::reader::EventReader;
use actix_web::{get, App, web, HttpResponse, HttpServer, Responder};
use crate::data::{Team, TeamLegend};
use tokio::time;
use std::time::Duration;
use actix::prelude::Actor;
use xml::reader::EventReader;
const DATE_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
@ -36,12 +37,12 @@ const DATE_FORMAT: &str = "%Y-%m-%dT%H:%M:%S";
enum Messages {
SynScores,
UpdateScores,
GetGames
GetGames,
}
#[derive(Debug)]
enum Responses {
GamesResult(Vec<Game>)
GamesResult(Vec<Game>),
}
impl<A, M> MessageResponse<A, M> for Responses
@ -58,7 +59,7 @@ where
#[derive(Default)]
struct GamesActor {
games: Arc<Mutex<Vec<Game>>>
games: Arc<Mutex<Vec<Game>>>,
}
impl Actor for GamesActor {
@ -77,34 +78,45 @@ impl Handler<Messages> for GamesActor {
// - update should only update games that are within 5 minutes of start or end time?
match msg {
Messages::SynScores => {
let games: Vec<Game> = games.lock().await.iter().map(|v| v.to_owned()).collect();
update_scores(games).await.expect("score calculation failed?")
},
let games: Vec<Game> =
games.lock().await.iter().map(|v| v.to_owned()).collect();
update_scores(games)
.await
.expect("score calculation failed?")
}
Messages::UpdateScores => {
let games = games.lock().await.iter().map(|v| v.to_owned()).collect();
update_scores(games).await.expect("score calculation failed?")
},
Messages::GetGames => {
games.lock().await.iter().map(|v| v.to_owned()).collect()
},
update_scores(games)
.await
.expect("score calculation failed?")
}
Messages::GetGames => games.lock().await.iter().map(|v| v.to_owned()).collect(),
}
}
.into_actor(self)
.map(move |games: Vec<Game>, this, _| {
this.games = Arc::new(Mutex::new(games.clone()));
Responses::GamesResult(games)
})
}),
))
}
}
#[get("/legend-data/{game_code}")]
async fn legend_data(game_code: web::Path<String>, games: web::Data<Arc<Mutex<Vec<Game>>>>) -> impl Responder {
async fn legend_data(
game_code: web::Path<String>,
games: web::Data<Arc<Mutex<Vec<Game>>>>,
) -> impl Responder {
let games = games.lock().await;
if let Some(game) = games.iter().find(|g| g.code == *game_code) {
let teams = &game.teams;
let legends: Vec<TeamLegend> = teams.iter()
.map(|t| TeamLegend{name: t.name.clone(), color:t.color.clone()}).collect();
let legends: Vec<TeamLegend> = teams
.iter()
.map(|t| TeamLegend {
name: t.name.clone(),
color: t.color.clone(),
})
.collect();
return HttpResponse::Ok()
.content_type("application/json")
.body(serde_json::to_string(&legends).unwrap());
@ -115,9 +127,13 @@ async fn legend_data(game_code: web::Path<String>, games: web::Data<Arc<Mutex<Ve
}
#[get("/geojson/{game_code}")]
async fn geojson_endpoint(game_code: web::Path<String>, games_actor: web::Data<Addr<GamesActor>>) -> impl Responder {
async fn geojson_endpoint(
game_code: web::Path<String>,
games_actor: web::Data<Addr<GamesActor>>,
) -> impl Responder {
if let Ok(Responses::GamesResult(games)) = games_actor.send(Messages::GetGames).await
&& let Some(game) = games.iter().find(|g| g.code == *game_code) {
&& let Some(game) = games.iter().find(|g| g.code == *game_code)
{
let teams = &game.teams;
let utils = geo_utils::GeoUtils::new();
let feature_collection = utils.get_colored_collection_copy(teams.to_vec());
@ -130,7 +146,7 @@ async fn geojson_endpoint(game_code: web::Path<String>, games_actor: web::Data<A
.body("{}")
}
#[get("/{game_code}")]
#[get("/game/{game_code}")]
async fn game_page(game_code: web::Path<String>) -> impl Responder {
let mut context = Context::new();
context.insert("game_code", game_code.as_str());
@ -139,6 +155,22 @@ async fn game_page(game_code: web::Path<String>) -> impl Responder {
HttpResponse::Ok().body(body)
}
#[get("/admin")]
async fn admin() -> impl Responder {
let mut context = Context::new();
let body = Tera::one_off(include_str!("templates/admin.tera"), &context, false)
.expect("Failed to render template");
HttpResponse::Ok().body(body)
}
#[get("/admin/games")]
async fn list_of_games(games: web::Data<Arc<Mutex<Vec<Game>>>>) -> impl Responder {
let games = games.lock().await;
let games_json = serde_json::to_string(&*games).unwrap();
HttpResponse::Ok()
.content_type("application/json")
.body(games_json)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
@ -149,11 +181,14 @@ async fn main() -> std::io::Result<()> {
let manager = SqliteConnectionManager::file("mapbattle.db");
let pool = Pool::new(manager).unwrap();
let games = db::games(&pool).await.expect("retirieving games from db should work fine");
let games = db::games(&pool)
.await
.expect("retirieving games from db should work fine");
let state = Arc::new(Mutex::new(games));
let actor_addr = GamesActor{
games: state.clone()
}.start();
let actor_addr = GamesActor {
games: state.clone(),
}
.start();
let sync_result = actor_addr.send(Messages::SynScores).await;
match sync_result {
Ok(_) => println!("Initial score syncing was successful"),
@ -179,29 +214,32 @@ async fn main() -> std::io::Result<()> {
.service(game_page)
.service(geojson_endpoint)
.service(legend_data)
//.service(admin)
//.service(list_of_games)
})
.bind(("0.0.0.0", 8080))?
.run()
.await
}
async fn update_scores(games: Vec<Game>)-> Result<Vec<Game>, Box<dyn Error>> {
let client = Client::builder()
.user_agent("MapBattle/0.1")
.build()?;
async fn update_scores(games: Vec<Game>) -> Result<Vec<Game>, Box<dyn Error>> {
let client = Client::builder().user_agent("MapBattle/0.1").build()?;
let mut result: Vec<Game> = vec![];
for game in games.iter() {
let mut neogame = game.clone();
let teams: &mut Vec<Team> = &mut neogame.teams;
let start_date = NaiveDateTime::parse_from_str(&game.start_time, DATE_FORMAT).expect("failed to parse start date");
let end_date = NaiveDateTime::parse_from_str(&game.end_time, DATE_FORMAT).expect("failed to parse end date");
let start_date = NaiveDateTime::parse_from_str(&game.start_time, DATE_FORMAT)
.expect("failed to parse start date");
let end_date = NaiveDateTime::parse_from_str(&game.end_time, DATE_FORMAT)
.expect("failed to parse end date");
for team in teams {
team.scores.clear();
for player in team.players.clone() {
println!("Processing player: {} ({})", player.username, team.name);
let body = scores::get_changesets_for_user(player.username.clone(), &client).await?;
let body =
scores::get_changesets_for_user(player.username.clone(), &client).await?;
let reader = EventReader::from_str(&body);
let changesets = scores::extract_changesets_from_reader(reader);
let changesets = scores::time_bound_changesets(changesets, start_date, end_date);

View File

@ -7,32 +7,34 @@ mod network {
}
pub async fn get_changesets(user: String, client: &Client) -> Result<String, Box<dyn Error>> {
let endpoint = format!("https://api.openstreetmap.org/api/0.6/changesets?display_name={user}");
let endpoint =
format!("https://api.openstreetmap.org/api/0.6/changesets?display_name={user}");
get_data(endpoint, client).await
}
}
pub mod scores {
use crate::geo_utils;
use crate::osm::network;
use chrono::NaiveDateTime;
use geo as geo_types;
use reqwest::Client;
use std::{collections::HashMap, error::Error};
use xml::{attribute::OwnedAttribute, reader::{EventReader, XmlEvent}};
use geo as geo_types;
use crate::geo_utils;
use xml::{
attribute::OwnedAttribute,
reader::{EventReader, XmlEvent},
};
const DATE_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
pub struct Team {
pub players: Vec<String>
pub players: Vec<String>,
}
pub struct Input {
pub start_time: NaiveDateTime,
pub end_time: NaiveDateTime,
pub teams: Vec<Team>
pub teams: Vec<Team>,
}
pub async fn calculate(input: Input)-> Result<(), Box<dyn Error>> {
let client = Client::builder()
.user_agent("MapBattle/0.1")
.build()?;
pub async fn calculate(input: Input) -> Result<(), Box<dyn Error>> {
let client = Client::builder().user_agent("MapBattle/0.1").build()?;
for team in input.teams {
for player in team.players {
let raw_changesets = network::get_changesets(player.clone(), &client).await?;
@ -47,77 +49,115 @@ pub mod scores {
}
// Public function for getting changesets for a user
pub async fn get_changesets_for_user(user: String, client: &Client) -> Result<String, Box<dyn Error>> {
pub async fn get_changesets_for_user(
user: String,
client: &Client,
) -> Result<String, Box<dyn Error>> {
network::get_changesets(user, client).await
}
// Public function for extracting changesets from reader
pub fn extract_changesets_from_reader(reader: EventReader<&[u8]>) -> Vec<Vec<OwnedAttribute>> {
reader.into_iter().filter_map(|event| match event {
Ok(XmlEvent::StartElement { name, attributes, .. }) => {
if name.local_name.eq("changeset") {
Some(attributes)
} else {
reader
.into_iter()
.filter_map(|event| match event {
Ok(XmlEvent::StartElement {
name, attributes, ..
}) => {
if name.local_name.eq("changeset") {
Some(attributes)
} else {
None
}
}
Err(e) => {
eprintln!("Warning: XML parsing error: {}", e);
None
}
},
Err(e) => {
eprintln!("Warning: XML parsing error: {}", e);
None
},
_ => None
}).collect()
_ => None,
})
.collect()
}
// Public function for time bounding changesets
pub fn time_bound_changesets(input: Vec<Vec<OwnedAttribute>>, start_date: NaiveDateTime, end_date: NaiveDateTime) -> Vec<Vec<OwnedAttribute>> {
input.into_iter().filter(|list| {
match list.iter().find(|att| att.name.local_name.eq("created_at")) {
Some(created_at) => {
match NaiveDateTime::parse_from_str(&created_at.value, DATE_FORMAT) {
Ok(date) => date > start_date && date < end_date,
Err(e) => {
eprintln!("Warning: Failed to parse date '{}': {}", created_at.value, e);
false
pub fn time_bound_changesets(
input: Vec<Vec<OwnedAttribute>>,
start_date: NaiveDateTime,
end_date: NaiveDateTime,
) -> Vec<Vec<OwnedAttribute>> {
input
.into_iter()
.filter(
|list| match list.iter().find(|att| att.name.local_name.eq("created_at")) {
Some(created_at) => {
match NaiveDateTime::parse_from_str(&created_at.value, DATE_FORMAT) {
Ok(date) => date > start_date && date < end_date,
Err(e) => {
eprintln!(
"Warning: Failed to parse date '{}': {}",
created_at.value, e
);
false
}
}
}
}
None => false
}
}).collect()
None => false,
},
)
.collect()
}
// Public function for converting changesets to points
pub async fn changesets_to_points(changesets: Vec<Vec<OwnedAttribute>>) -> HashMap<String, i32> {
pub async fn changesets_to_points(
changesets: Vec<Vec<OwnedAttribute>>,
) -> HashMap<String, i32> {
let mut result: HashMap<String, i32> = HashMap::new();
for changeset in changesets {
let min_lat = changeset.clone().into_iter().find(|att| att.name.local_name.eq("min_lat"));
let min_lat = changeset
.clone()
.into_iter()
.find(|att| att.name.local_name.eq("min_lat"));
if min_lat.is_none() {
continue;
}
let min_lat = min_lat.unwrap().value;
let min_lon = changeset.clone().into_iter().find(|att| att.name.local_name.eq("min_lon"));
let min_lon = changeset
.clone()
.into_iter()
.find(|att| att.name.local_name.eq("min_lon"));
if min_lon.is_none() {
continue;
}
let min_lon = min_lon.unwrap().value;
let min_lat: f64 = min_lat.parse().unwrap_or_default();
let min_lon: f64 = min_lon.parse().unwrap_or_default();
let score = changeset.into_iter().find(|att| att.name.local_name.eq("changes_count")).unwrap().value;
let score:i32 = score.parse().expect("changes count returned as not a string");
let score = changeset
.into_iter()
.find(|att| att.name.local_name.eq("changes_count"))
.unwrap()
.value;
let score: i32 = score
.parse()
.expect("changes count returned as not a string");
let point = geo_types::Point::new(min_lon, min_lat);
let territory = geo_utils::GeoUtils::new().get_territory_for(point);
result.entry(territory).and_modify(|val| *val += score).or_insert(score);
result
.entry(territory)
.and_modify(|val| *val += score)
.or_insert(score);
}
result
}
fn time_bound(input: Vec<Vec<OwnedAttribute>>, start_date: NaiveDateTime, end_date: NaiveDateTime) -> Vec<Vec<OwnedAttribute>> {
fn time_bound(
input: Vec<Vec<OwnedAttribute>>,
start_date: NaiveDateTime,
end_date: NaiveDateTime,
) -> Vec<Vec<OwnedAttribute>> {
time_bound_changesets(input, start_date, end_date)
}
async fn to_points(changesets: Vec<Vec<OwnedAttribute>>) -> HashMap<String, i32> {
changesets_to_points(changesets).await
}
}
}

75
src/templates/admin.tera Normal file
View File

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html>
<head>
<title>Admin Page</title>
<style>
table {
border-collapse: collapse;
border: 2px solid rgb(140 140 140);
font-family: sans-serif;
font-size: 0.8rem;
letter-spacing: 1px;
}
caption {
caption-side: bottom;
padding: 10px;
font-weight: bold;
}
thead,
tfoot {
background-color: rgb(228 240 245);
}
th,
td {
border: 1px solid rgb(160 160 160);
padding: 8px 10px;
}
</style>
</head>
<body>
<h1>games</h1>
<form id="games_form"><button type="submit">Load/Refresh</button></form>
<table>
<caption> Games table </caption>
<thead><tr>
<th scope="col">code</th>
<th scope="col">team</th>
<th scope="col">start_time</th>
<th scope="col">end_time</th>
</tr></thead>
<tbody id="games_table"></tbody>
</table>
<script>
const games_form = document.getElementById("games_form");
function refresh_games(event) {
event.preventDefault();
fetch("/admin/games")
.then(response => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
})
.then(data => {
const table_body = document.getElementById("games_table");
let table_content = "";
data.forEach((item,index) => {
table_content += '<tr>';
table_content += '<th scope="row">' + item['code'] + '</th>';
table_content += '<td>teams should go here</td>';
table_content += '<td>' + item['start_time'] + '</td>';
table_content += '<td>' + item['end_time'] + '</td>';
});
table_body.innerHTML = table_content;
})
.catch(error => {
console.error("Fetching legend data failed:", error);
});
}
games_form.addEventListener("submit", refresh_games);
</script>
</body>
</html>