commit ff630ec7a1242d8de71981fd6818defb8f873f22
parent 40b757f3d9a9f5a36ec245a76b78ad53556c9dd7
Author: Alex Wennerberg <alex@Alexs-MacBook-Air.local>
Date: Wed, 17 Dec 2025 15:17:51 -0800
App revamp
Diffstat:
8 files changed, 285 insertions(+), 165 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1,3 +1,4 @@
+*.swp
*.json
allcards.txt
*.db
diff --git a/README.md b/README.md
@@ -5,10 +5,7 @@ Utilities for pulling data from 3 card blind metashape:
https://sites.google.com/view/3cb-metashape/home
-(Working on Ruby rewrite)
-
Pull all cards from mtgjson https://mtgjson.com/downloads/all-files/
-
wget https://mtgjson.com/api/v5/AtomicCards.json
cat AtomicCards.json | jq -r '.data | keys[]' > allcards.txt
diff --git a/app.py b/app.py
@@ -1,14 +1,46 @@
-from flask import Flask, render_template, request
+from flask import Flask, render_template, request, g
import sqlite3
from urllib.parse import quote as url_quote
+import time
+import logging
app = Flask(__name__)
+logging.basicConfig(
+ level=logging.DEBUG,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+@app.before_request
+def before_request():
+ g.start_time = time.time()
+
+@app.after_request
+def after_request(response):
+ if hasattr(g, 'start_time'):
+ duration_ms = (time.time() - g.start_time) * 1000
+ logger.info(f"{request.method} {request.path} - {response.status_code} - {duration_ms:.2f}ms")
+ return response
+
def get_db():
db = sqlite3.connect('3cb.db')
db.row_factory = sqlite3.Row
+ db.set_trace_callback(lambda stmt: logger.debug(f"SQLite: {stmt}"))
return db
+def execute_query(db, query, params=()):
+ """Execute a query with logging of query text and execution time."""
+ start_time = time.time()
+ result = db.execute(query, params)
+ duration_ms = (time.time() - start_time) * 1000
+
+ # Format query for logging (remove extra whitespace/newlines)
+ formatted_query = ' '.join(query.split())
+ logger.info(f"SQL Query ({duration_ms:.2f}ms): {formatted_query} | Params: {params}")
+
+ return result
+
@app.route('/')
def index():
db = get_db()
@@ -21,7 +53,18 @@ def index():
def round_detail(id):
db = get_db()
round_data = db.execute("select * from round where id = ?", (id,)).fetchone()
- matches = db.execute("select * from round_score where round = ? order by final_score desc,prelim_score desc", (id,)).fetchall()
+ matches = db.execute("""
+ select rs.*,
+ m1.cost as card1_cost, m1.card_type as card1_type,
+ m2.cost as card2_cost, m2.card_type as card2_type,
+ m3.cost as card3_cost, m3.card_type as card3_type
+ from round_score rs
+ left join mtg m1 on rs.card1 = m1.name
+ left join mtg m2 on rs.card2 = m2.name
+ left join mtg m3 on rs.card3 = m3.name
+ where round = ?
+ order by final_score desc, prelim_score desc
+ """, (id,)).fetchall()
banned_cards = [row["name"] for row in db.execute("select name from ban").fetchall()]
db.close()
subtitle = "round " + str(id)
@@ -31,7 +74,18 @@ def round_detail(id):
def card():
name = request.args.get('name')
db = get_db()
- matches = db.execute("select * from round_score where card1 = ?1 or card2 = ?1 or card3 = ?1 order by round desc", (name,)).fetchall()
+ matches = db.execute("""
+ select rs.*,
+ m1.cost as card1_cost, m1.card_type as card1_type,
+ m2.cost as card2_cost, m2.card_type as card2_type,
+ m3.cost as card3_cost, m3.card_type as card3_type
+ from round_score rs
+ left join mtg m1 on rs.card1 = m1.name
+ left join mtg m2 on rs.card2 = m2.name
+ left join mtg m3 on rs.card3 = m3.name
+ where card1 = ?1 or card2 = ?1 or card3 = ?1
+ order by round desc
+ """, (name,)).fetchall()
banned_cards = [row["name"] for row in db.execute("select name from ban").fetchall()]
db.close()
subtitle = name
@@ -41,7 +95,18 @@ def card():
def player():
name = request.args.get('name')
db = get_db()
- matches = db.execute("select * from round_score where player = ? order by round desc", (name,)).fetchall()
+ matches = db.execute("""
+ select rs.*,
+ m1.cost as card1_cost, m1.card_type as card1_type,
+ m2.cost as card2_cost, m2.card_type as card2_type,
+ m3.cost as card3_cost, m3.card_type as card3_type
+ from round_score rs
+ left join mtg m1 on rs.card1 = m1.name
+ left join mtg m2 on rs.card2 = m2.name
+ left join mtg m3 on rs.card3 = m3.name
+ where player = ?
+ order by round desc
+ """, (name,)).fetchall()
banned_cards = [row["name"] for row in db.execute("select name from ban").fetchall()]
db.close()
subtitle = "player " + name
@@ -52,14 +117,23 @@ def deck():
cards = sorted([request.args.get('c1'), request.args.get('c2'), request.args.get('c3')])
db = get_db()
matches = db.execute("""
- select * from round_score where
- card1 = ?1 and card2 = ?2 and card3 = ?3 or
- card1 = ?1 and card2 = ?3 and card3 = ?2 or
- card1 = ?2 and card2 = ?1 and card3 = ?3 or
- card1 = ?2 and card2 = ?3 and card3 = ?1 or
- card1 = ?3 and card2 = ?1 and card3 = ?2 or
- card1 = ?3 and card2 = ?2 and card3 = ?1
- order by round desc""", (cards[0], cards[1], cards[2])).fetchall()
+ select rs.*,
+ m1.cost as card1_cost, m1.card_type as card1_type,
+ m2.cost as card2_cost, m2.card_type as card2_type,
+ m3.cost as card3_cost, m3.card_type as card3_type
+ from round_score rs
+ left join mtg m1 on rs.card1 = m1.name
+ left join mtg m2 on rs.card2 = m2.name
+ left join mtg m3 on rs.card3 = m3.name
+ where
+ card1 = ?1 and card2 = ?2 and card3 = ?3 or
+ card1 = ?1 and card2 = ?3 and card3 = ?2 or
+ card1 = ?2 and card2 = ?1 and card3 = ?3 or
+ card1 = ?2 and card2 = ?3 and card3 = ?1 or
+ card1 = ?3 and card2 = ?1 and card3 = ?2 or
+ card1 = ?3 and card2 = ?2 and card3 = ?1
+ order by round desc
+ """, (cards[0], cards[1], cards[2])).fetchall()
banned_cards = [row["name"] for row in db.execute("select name from ban").fetchall()]
db.close()
subtitle = "; ".join(cards)
@@ -112,5 +186,38 @@ def cards():
def url_encode_filter(s):
return url_quote(str(s))
+# Template filter for mana symbols using Mana font
+@app.template_filter('mana_symbols')
+def mana_symbols_filter(s):
+ if not s or s == 'N/A':
+ return s
+
+ import re
+
+ # Parse mana symbols like {W}, {U}, {1}, {2/U}, etc.
+ def replace_symbol(match):
+ symbol = match.group(1).lower()
+
+ # Handle hybrid mana (e.g., {W/U}, {2/W})
+ if '/' in symbol:
+ parts = symbol.split('/')
+ # Check if first part is a number (e.g., 2/W for hybrid)
+ if parts[0].isdigit():
+ return f'<i class="ms ms-{parts[1]} ms-cost ms-shadow"></i>'
+ else:
+ return f'<i class="ms ms-{parts[0]}{parts[1]} ms-cost ms-shadow"></i>'
+ # Handle Phyrexian mana (e.g., {W/P})
+ elif 'p' in symbol:
+ color = symbol.replace('/p', '').replace('p', '')
+ return f'<i class="ms ms-{color} ms-p ms-cost ms-shadow"></i>'
+ # Regular mana
+ else:
+ return f'<i class="ms ms-{symbol} ms-cost ms-shadow"></i>'
+
+ # Replace all mana symbols
+ result = re.sub(r'\{([^}]+)\}', replace_symbol, s)
+
+ return result
+
if __name__ == '__main__':
app.run(debug=False)
diff --git a/getdata.py b/getdata.py
@@ -1,26 +1,21 @@
# set GOOG_KEY env variable to a valid api key
# all rounds available at https://sites.google.com/view/3cb-metashape/pairings-results/past-results?authuser=0
-# depends on requests and stdlib
-# writes to 3cmdata.csv
-
-import requests, os, csv, re, io, sqlite3, titlecase, string, openpyxl, urllib
+import requests, os, csv, re, io, sqlite3, titlecase, string, openpyxl, urllib, json
+from datetime import datetime, timedelta
from Levenshtein import distance
from bs4 import BeautifulSoup
con = sqlite3.connect("3cb.db")
-allcards = set(open('allcards.txt','r').read().splitlines())
-
-# cache
-def fileids_in_db():
- return [a[0] for a in con.cursor().execute("select fileid from round").fetchall()]
+con.set_trace_callback(print)
+allcards= [a[0] for a in con.cursor().execute("select name from mtg").fetchall()]
def bans_from_db():
return [a[0] for a in con.cursor().execute("select name from bans").fetchall()]
-def main():
- cache = fileids_in_db()
+def run_rounds():
+ file_ids_in_db = [a[0] for a in con.cursor().execute("select fileid from round").fetchall()]
for n, file in enumerate(get_round_fileids()):
- if file in cache:
+ if file in file_ids_in_db:
continue
if file not in cache and file != "1LGHvTrQz2zhBjz1PhlW61ZYmwRWJyWkEHj-png7ZKdw": # misc file
print(f"analyzing round {n+1}...")
@@ -58,7 +53,6 @@ def get_file_created_date(fileid):
data = res.json()
if "createdTime" in data:
# Parse ISO format and return YYYY-MM-DD
- from datetime import datetime
created_time = datetime.fromisoformat(data["createdTime"].replace('Z', '+00:00'))
return created_time.strftime('%Y-%m-%d')
return None
@@ -82,34 +76,10 @@ def update_bans():
cur.execute("insert into ban values (?)", (clean_card(card),))
con.commit()
-# not really normalized well
-schema = ["""
-create table if not exists round (
- id integer primary key,
- fileid text not null,
- date text
-);
-""",
-"""
-create table if not exists deck (
- round integer not null,
- player varchar not null,
- card1 text,
- card2 text,
- card3 text
-);""","""
-create table if not exists match (
- round integer,
- group_name text,
- player text,
- opp_player text,
- score integer
-);""","""
-create view if not exists card as
- select round,player,card1 as card from deck
- union select round,player,card2 as card from deck
- union select round,player,card3 as card from deck;
-"""]
+def load_schema():
+ with open('schema.sql', 'r') as f:
+ schema_sql = f.read()
+ con.executescript(schema_sql)
def save_sheet(cur, sheet, n, final):
sheetrows = list(sheet.rows)
@@ -185,86 +155,115 @@ def best_guess(card):
return card
-# data cleaning
-def replacement(card):
- m = {
- "Urborg Tomb Yawgy": "Urborg, Tomb of Yawgmoth",
- "Urborg (The One That Makes All Lands Swamps)": "Urborg, Tomb of Yawgmoth",
- "Ulamog the Infinite Gyre (Borderless) (Foil)": "Ulamog, the Infinite Gyre",
- "That One Wurm That Makes the Three 5/5s When It Dies I Have Done Too Many Scryfall Searches Today Sorry": "TBD",
- "Thallid Oh Yeah": "Thallid",
- "Tabernacle at Penrall Vale": "The Tabernacle at Pendrell Value",
- "Swamp (XLN 270)": "Swamp",
- "Swamp (BRB 18)": "Swamp",
- "Swamp (8ED #339)": "Swamp",
- "Speaker of the Heavens!?!?!?!": "Speaker of the Heavens",
- "Restore Balanse (I Have to Misspell This Cuz the Regex Is Buggy Lmao)": "Restore Balance",
- "Red Sun's Zenith (Again)": "Red Sun's Zenith",
- "Plains (ONS 333)": "Plains",
- "Phelps, Exuberant Swimmer (Phelia)": "Phelia, Exuberant Shepherd",
- "Phelia, Tail-Wagging Shepherd": "Phelia, Exuberant Shepherd",
- "Nesumi Shortfang": "Nezumi Shortfang // Stabwhisker the Odious",
- "Mox Pearl (The One Without Metalcraft)": "Mox Pearl",
- "Monty Python and the Holy Grail Black Knight (Oathsworn Knight)": "Oathsworn Knight",
- "Mayor of Avarbruck": "Mayor of Avabruck // Howlpack Alpha",
- "Lion's Eye Diamond Cheatyface": "Lion's Eye Diamond",
- "Karaka (Its Listed as Unbanned but the Form Wont Let Me Submit It Idk Its My First Time Lol)": "Karakas",
- "Island [KLD #395]": "Island",
- "Ink Mothy Nexy": "Inkmoth Nexus",
- "Gavel of the Righteous, for I Am Stubborn and Naively Hopeful": "Gavel of the Righteous",
- "Gargadon (Neither Greater Nor Lesser)": "Gargadon",
- "Forge-Chan": "Chancellor of the Forge",
- "Forest!!!!!!": "Forest",
- "Forest 🌲": "Forest",
- "Forest (Tempest #348)": "Forest",
- "Filigree Sylex": "The Filigree Sylex",
- "Elspeth Suns Champion Wooooooo": "Elspeth, Sun's Champion",
- "Dwarven Hold (Again)": "Dwarven Hold",
- "Dreams of Oil and Steel": "Dreams of Steel and Oil",
- "Dark Ritual (STA #89)": "Dark Ritual",
- "Dark Ritual (BTD 21)": "Dark Ritual",
- "Chronomatonton (The 1 Cost 1/1 That Taps to Get Bigger)": "Chronomaton",
- "Chancelor of the Tangle (Sic)": "Chancellor of the Tangle",
- "Bottomless Depths": "Bottomless Vault",
- "Boseiju, Who Destroys Target Artifact, Enchantment, or Nonbasic Land (Who Endures)": "Boseiju, Who Endures",
- "Bayou - Not Legal": "Bayou",
- "Basic Plains": "Plains",
- "Annex-Chan": "Chancellor of the Annex",
- "Annex Chan": "Chancellor of the Annex",
- "Azorius Chancery First": "Azorius Chancery",
- "Azorius Guildgate Anniversary": "Azorius Guildgate",
- "Chaplain of Arms": "Chaplain of Alms // Chapel Shieldgeist",
- "Fizik, Etherium Mechanic": "Fizik, Etherium Mechanic",
- "Forest [LCI #402]": "Forest",
- "Inky Mothy Nexy": "Inkmoth Nexus",
- "Island (ONE #273)": "Island",
- "Kytheon Hero of Akros": "Kytheon, Hero of Akros // Gideon, Battle-Forged",
- "Magus of the Mooooooooon": "Magus of the Moon",
- "Miku, Divine Diva": "Elspeth Tirel",
- "Senu, the Keen-Eyed": "Senu, Keen-Eyed Protector",
- "Signaling Roar": "Riling Dawnbreaker // Signaling Roar",
- "Simispiriguide": "Simian Spirit Guide",
- "Swamp (DOM #258)": "Swamp",
- "Witch-Blessed Meadow": "Witch's Cottage",
- "Yera and Oski, Weaver and Guide": "Yera and Oski, Weaver and Guide",
- "You Guessed It, Magus of the Moooon": "Magus of the Moon",
- }
- if card in m:
- return m[card]
- return card
+replacement = {
+ "You Guessed It, Magus of the Moooon": "Magus of the Moon",
+ "Yera and Oski, Weaver and Guide": "Yera and Oski, Weaver and Guide",
+ "Yera and Oski, Weaver and Guide": "Arachne, Psionic Weaver",
+ "Witch-Blessed Meadow": "Witch's Cottage",
+ "Urborg Tomb Yawgy": "Urborg, Tomb of Yawgmoth",
+ "Urborg (The One That Makes All Lands Swamps)": "Urborg, Tomb of Yawgmoth",
+ "Ulamog the Infinite Gyre (Borderless) (Foil)": "Ulamog, the Infinite Gyre",
+ "That One Wurm That Makes the Three 5/5s When It Dies I Have Done Too Many Scryfall Searches Today Sorry": "TBD",
+ "Thallid Oh Yeah": "Thallid",
+ "Tabernacle at Penrall Vale": "The Tabernacle at Pendrell Value",
+ "Swamp (XLN 270)": "Swamp",
+ "Swamp (DOM #258)": "Swamp",
+ "Swamp (BRB 18)": "Swamp",
+ "Swamp (8ED #339)": "Swamp",
+ "Speaker of the Heavens!?!?!?!": "Speaker of the Heavens",
+ "Simispiriguide": "Simian Spirit Guide",
+ "Signaling Roar": "Riling Dawnbreaker // Signaling Roar",
+ "Senu, the Keen-Eyed": "Senu, Keen-Eyed Protector",
+ "Restore Balanse (I Have to Misspell This Cuz the Regex Is Buggy Lmao)": "Restore Balance",
+ "Red Sun's Zenith (Again)": "Red Sun's Zenith",
+ "Plains (ONS 333)": "Plains",
+ "Phelps, Exuberant Swimmer (Phelia)": "Phelia, Exuberant Shepherd",
+ "Phelia, Tail-Wagging Shepherd": "Phelia, Exuberant Shepherd",
+ "Nesumi Shortfang": "Nezumi Shortfang // Stabwhisker the Odious",
+ "Mox Pearl (The One Without Metalcraft)": "Mox Pearl",
+ "Monty Python and the Holy Grail Black Knight (Oathsworn Knight)": "Oathsworn Knight",
+ "Miku, Divine Diva": "Elspeth Tirel",
+ "Mayor of Avarbruck": "Mayor of Avabruck // Howlpack Alpha",
+ "Magus of the Mooooooooon": "Magus of the Moon",
+ "Lion's Eye Diamond Cheatyface": "Lion's Eye Diamond",
+ "Kytheon Hero of Akros": "Kytheon, Hero of Akros // Gideon, Battle-Forged",
+ "Karaka (Its Listed as Unbanned but the Form Wont Let Me Submit It Idk Its My First Time Lol)": "Karakas",
+ "Island [KLD #395]": "Island",
+ "Island (ONE #273)": "Island",
+ "Inky Mothy Nexy": "Inkmoth Nexus",
+ "Ink Mothy Nexy": "Inkmoth Nexus",
+ "Gavel of the Righteous, for I Am Stubborn and Naively Hopeful": "Gavel of the Righteous",
+ "Gargadon (Neither Greater Nor Lesser)": "Gargadon",
+ "Forge-Chan": "Chancellor of the Forge",
+ "Forest!!!!!!": "Forest",
+ "Forest 🌲": "Forest",
+ "Forest [LCI #402]": "Forest",
+ "Forest (Tempest #348)": "Forest",
+ "Fizik, Etherium Mechanic": "Iron Spider, Stark Upgrade",
+ "Fizik, Etherium Mechanic": "Fizik, Etherium Mechanic",
+ "Filigree Sylex": "The Filigree Sylex",
+ "Elspeth Suns Champion Wooooooo": "Elspeth, Sun's Champion",
+ "Dwarven Hold (Again)": "Dwarven Hold",
+ "Dreams of Oil and Steel": "Dreams of Steel and Oil",
+ "Dark Ritual (STA #89)": "Dark Ritual",
+ "Dark Ritual (BTD 21)": "Dark Ritual",
+ "Chronomatonton (The 1 Cost 1/1 That Taps to Get Bigger)": "Chronomaton",
+ "Chaplain of Arms": "Chaplain of Alms // Chapel Shieldgeist",
+ "Chancelor of the Tangle (Sic)": "Chancellor of the Tangle",
+ "Bottomless Depths": "Bottomless Vault",
+ "Boseiju, Who Destroys Target Artifact, Enchantment, or Nonbasic Land (Who Endures)": "Boseiju, Who Endures",
+ "Bayou - Not Legal": "Bayou",
+ "Basic Plains": "Plains",
+ "Azorius Guildgate Anniversary": "Azorius Guildgate",
+ "Azorius Chancery First": "Azorius Chancery",
+ "Annex-Chan": "Chancellor of the Annex",
+ "Annex Chan": "Chancellor of the Annex",
+ }
def clean_card(card_name):
clean = titlecase.titlecase(card_name.strip())
clean = ''.join(filter(lambda x: x in string.printable, clean))
clean = clean.replace("’", "'")
- clean = replacement(clean)
+ clean = replacement.get(clean) or clean
if clean not in allcards:
return best_guess(clean)
return clean
+cache_file = "AtomicCards.json"
+# update the db with all valid mtg cards
+def update_mtg_db():
+ updated = None
+ if os.path.exists(cache_file):
+ updated = datetime.now() - datetime.fromtimestamp(os.path.getmtime(cache_file))
+ if (not updated) or updated > timedelta(days=30):
+ print("Updating AtomicCards (~100MB)")
+ cards = requests.get("https://mtgjson.com/api/v5/AtomicCards.json")
+ cards_data = cards.json()
+ with open(cache_file, 'w') as f:
+ json.dump(cards_data, f)
+ return
+
+def populate_mtg_table():
+ with open(cache_file, 'r') as f:
+ cards_data = json.load(f)
+ cur = con.cursor()
+ cur.execute("delete from mtg")
+ print("Populating mtg table...")
+ count = 0
+ for card_name, card_variants in cards_data.get('data', {}).items():
+ if card_variants:
+ card = card_variants[0]
+ name = card.get('name', card_name)
+ cost = card.get('manaCost', '')
+ card_type = " ".join(card.get('types', ''))
+ cur.execute("insert into mtg values (?, ?, ?)", (name, cost, card_type))
+ con.commit()
+ return
+
if __name__ == "__main__":
- for table in schema:
- con.execute(table)
+ load_schema()
+ update_mtg_db()
+ populate_mtg_table()
update_bans()
- main()
+ run_rounds()
diff --git a/requirements.txt b/requirements.txt
@@ -1 +1,6 @@
-Flask==2.3.3
-\ No newline at end of file
+Flask==2.3.3
+requests
+titlecase
+Levenshtein
+openpyxl
+beautifulsoup4
diff --git a/schema.sql b/schema.sql
@@ -1,39 +1,45 @@
-CREATE TABLE round (
+-- card name db
+create table if not exists mtg (
+ name text,
+ cost text,
+ card_type text
+);
+CREATE TABLE if not exists round (
id integer primary key,
fileid text not null,
date text
);
-CREATE TABLE deck (
+CREATE TABLE if not exists deck (
round integer not null,
player varchar not null,
card1 text,
card2 text,
card3 text
);
-CREATE TABLE match (
+CREATE TABLE if not exists match (
round integer,
group_name text,
player text,
opp_player text,
score integer
);
-CREATE VIEW card as
+CREATE VIEW if not exists card as
select round,player,card1 as card from deck
union select round,player,card2 as card from deck
union select round,player,card3 as card from deck
/* card(round,player,card) */;
-CREATE INDEX matchidx on match(round);
-CREATE INDEX deckidx on deck(round);
-CREATE INDEX matchidx2 on match(player);
-CREATE INDEX deckidx2 on deck(player);
-CREATE VIEW rank as
+CREATE INDEX if not exists matchidx on match(round);
+CREATE INDEX if not exists deckidx on deck(round);
+CREATE INDEX if not exists matchidx2 on match(player);
+CREATE INDEX if not exists deckidx2 on deck(player);
+CREATE VIEW if not exists rank as
select round,player,rank() over (partition by round order by
sum(case when match.group_name = 'final' then match.score else null end) desc nulls last,
sum(case when match.group_name != 'final' then match.score else null end) desc
) as rank
from match group by 1,2 order by 3 desc
/* rank(round,player,rank) */;
-CREATE VIEW round_score as
+CREATE VIEW if not exists round_score as
select deck.round, deck.player,
card1,
card2,
@@ -49,7 +55,7 @@ join rank
on deck.round = rank.round and deck.player = rank.player
group by 1,2
/* round_score(round,player,card1,card2,card3,prelim_group,prelim_score,final_score,rank,num_players) */;
-CREATE TABLE ban (name text);
-CREATE INDEX c1idx on deck(card1);
-CREATE INDEX c2idx on deck(card2);
-CREATE INDEX c3idx on deck(card3);
+CREATE TABLE if not exists ban (name text);
+CREATE INDEX if not exists c1idx on deck(card1);
+CREATE INDEX if not exists c2idx on deck(card2);
+CREATE INDEX if not exists c3idx on deck(card3);
diff --git a/templates/header.html b/templates/header.html
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Data analysis for 3 card blind metashape" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='style.css') }}">
+<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/mana-font@latest/css/mana.min.css">
<title>3 card blind data analysis</title>
</head>
<body>
diff --git a/templates/matches.html b/templates/matches.html
@@ -4,25 +4,31 @@
{% if not matches %}
<p>This card has never been played, or may be spelled wrong!</p>
{% else %}
-<div class="flextable">
- {% for match in matches %}
- <div class="deck-box"
- {% if match.rank == 1 %} style="background:#c5ffc5" {% elif match.final_score %} style="background:#ffffc5"{% endif %}>
- <div style="width:160px">
- <b><a href='/player?name={{ match.player }}'>{{ match.player }}</a></b><br>
- <a href="/round/{{ match.round }}">Round {{ match.round }}</a><br>
- Rank: {{ match.rank }}<br>
- Groups{{ match.group_name }}: {{ match.prelim_score }}
-{% if match.final_score %}<br>
- Finals: {{ match.final_score }}
-{% endif %}<br>
- <a href="/deck?c1={{ match.card1 }}&c2={{ match.card2 }}&c3={{ match.card3 }}">deck</a><br>
-</div>
-<div>
- {% for card in [match.card1, match.card2, match.card3] %}<div style="position: relative; display: inline-block;"><a href="/card?name={{ card }}"><img alt='{{ card }}' width=146 height=204px loading=lazy src="https://api.scryfall.com/cards/named?exact={{ card|url_encode }}&format=image&version=medium" /></a>{% if card in banned_cards %}<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(255, 0, 0, 0.7); color: white; font-weight: bold; font-size: 20px; padding: 5px 10px; border-radius: 5px;">BANNED</div>{% endif %}</div>{% endfor %}
-</div>
-</div>
-{% endfor %}
-</div>
+<table style="border-collapse: collapse; width: 100%;">
+ <tr>
+ <th style="border: 1px solid #ddd; padding: 8px;">Player</th>
+ <th style="border: 1px solid #ddd; padding: 8px;">Round</th>
+ <th style="border: 1px solid #ddd; padding: 8px;">Rank</th>
+ <th style="border: 1px solid #ddd; padding: 8px;">Groups</th>
+ <th style="border: 1px solid #ddd; padding: 8px;">Finals</th>
+ <th style="border: 1px solid #ddd; padding: 8px;">Card 1</th>
+ <th style="border: 1px solid #ddd; padding: 8px;">Card 2</th>
+ <th style="border: 1px solid #ddd; padding: 8px;">Card 3</th>
+ <th style="border: 1px solid #ddd; padding: 8px;">Deck</th>
+ </tr>
+ {% for match in matches %}
+ <tr {% if match.rank == 1 %}style="background:#c5ffc5"{% elif match.final_score %}style="background:#ffffc5"{% endif %}>
+ <td style="border: 1px solid #ddd; padding: 8px;"><a href='/player?name={{ match.player }}'>{{ match.player }}</a></td>
+ <td style="border: 1px solid #ddd; padding: 8px;"><a href="/round/{{ match.round }}">{{ match.round }}</a></td>
+ <td style="border: 1px solid #ddd; padding: 8px;">{{ match.rank }}</td>
+ <td style="border: 1px solid #ddd; padding: 8px;">{{ match.prelim_score }}</td>
+ <td style="border: 1px solid #ddd; padding: 8px;">{{ match.final_score or '' }}</td>
+ <td style="border: 1px solid #ddd; padding: 8px;"><a href="/card?name={{ match.card1 }}">{{ match.card1 }}</a>{% if match.card1 in banned_cards %} <span style="color: red;">(banned)</span>{% endif %}<br>{{ match.card1_type or 'N/A' }}{% if match.card1_cost %} {{ match.card1_cost|mana_symbols|safe }}{% endif %}</td>
+ <td style="border: 1px solid #ddd; padding: 8px;"><a href="/card?name={{ match.card2 }}">{{ match.card2 }}</a>{% if match.card2 in banned_cards %} <span style="color: red;">(banned)</span>{% endif %}<br>{{ match.card2_type or 'N/A' }}{% if match.card2_cost %} {{ match.card2_cost|mana_symbols|safe }}{% endif %}</td>
+ <td style="border: 1px solid #ddd; padding: 8px;"><a href="/card?name={{ match.card3 }}">{{ match.card3 }}</a>{% if match.card3 in banned_cards %} <span style="color: red;">(banned)</span>{% endif %}<br>{{ match.card3_type or 'N/A' }}{% if match.card3_cost %} {{ match.card3_cost|mana_symbols|safe }}{% endif %}</td>
+ <td style="border: 1px solid #ddd; padding: 8px;"><a href="/deck?c1={{ match.card1 }}&c2={{ match.card2 }}&c3={{ match.card3 }}">deck</a></td>
+ </tr>
+ {% endfor %}
+</table>
{% endif %}
-{% include 'footer.html' %}
-\ No newline at end of file
+{% include 'footer.html' %}