This commit is contained in:
John Gatward
2026-03-19 21:07:16 +00:00
commit a69ef51825
18 changed files with 1000 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

257
src/app.py Normal file
View File

@@ -0,0 +1,257 @@
from flask import Flask, render_template
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import statsmodels.api as sm
import numpy as np
import datetime
from sklearn.linear_model import LinearRegression
import json
from stats import generate_stats
from player_table import generate_player_table
import constants
app = Flask(__name__)
def get_data_frame(filename):
df = pd.read_csv(filename)
df["Date"] = pd.to_datetime(df["Date"], dayfirst=True)
df = df.sort_values("Date").reset_index(drop=True)
return df
def build_hovertext(df, attendance_columns):
return df[attendance_columns].apply(
lambda row: ", ".join(
[
player
for player in attendance_columns
if row[player] == 1
]
) or "No attendance",
axis=1
)
def generate_weekly_attendance_calendar(df):
# Compute ISO year/week and attendance
df["Year"] = df["Date"].dt.isocalendar().year
df["Week"] = df["Date"].dt.isocalendar().week
attendee_columns = [
col for col in df.columns if col not in {
"Date", "Relative Position", "Number of Players",
"Number of Teams", "Attendees", "Year", "Week", "Year-Week"
}
]
df["Attended"] = df[attendee_columns].sum(axis=1) > 0
weekly_attendance = df.groupby(["Year", "Week"])[
"Attended"].any().astype(int).reset_index()
# Build full year/week grid
all_years = sorted(df["Year"].unique())
all_weeks = list(range(1, 53))
grid = []
for year in all_years:
for week in all_weeks:
grid.append({"Year": year, "Week": week})
calendar = pd.DataFrame(grid)
calendar = calendar.merge(weekly_attendance, on=[
"Year", "Week"], how="left").fillna(0)
calendar["Attended"] = calendar["Attended"].astype(int)
# Plot
fig = go.Figure(data=go.Heatmap(
x=calendar["Week"],
y=calendar["Year"],
z=calendar["Attended"],
colorscale=constants.ATTENDANCE_COLORSCHEME,
zmin=0,
zmax=1,
showscale=False
))
fig.update_layout(
title="Pub Quiz Attendance Calendar (Weekly)",
xaxis_title="Week Number",
yaxis_title="Year",
xaxis=dict(tickmode="linear", dtick=4),
template="plotly_white",
height=180 + len(all_years) * 40
)
return fig
def generate_relative_position_over_time(df):
df["Date_ordinal"] = df["Date"].map(pd.Timestamp.toordinal)
X = sm.add_constant(df["Date_ordinal"])
y = df["Relative Position"]
model = sm.OLS(y, X).fit()
df["BestFit"] = model.predict(X)
intercept = model.params["const"]
slope = model.params["Date_ordinal"]
target_value = 0.08
predicted_ordinal = (target_value - intercept) / slope
min_ordinal = df["Date_ordinal"].min()
max_ordinal = df["Date_ordinal"].max()
if predicted_ordinal > max_ordinal:
extended_ordinals = np.linspace(min_ordinal, predicted_ordinal, 100)
else:
extended_ordinals = np.linspace(min_ordinal, max_ordinal, 100)
extended_bestfit = intercept + slope * extended_ordinals
extended_dates = [datetime.date.fromordinal(
int(x)) for x in extended_ordinals]
df["Attendees"] = build_hovertext(df, constants.PLAYER_NAME_COLUMNS)
fig = px.line(
df,
x="Date",
y="Relative Position",
title="Quiz Position Over Time with Extended Trendline",
hover_data={"Attendees": True}
)
fig.add_scatter(
x=extended_dates,
y=extended_bestfit,
mode="lines",
name="Extended Trendline",
line=dict(dash="dot", color="red")
)
fig.update_yaxes(range=[0, 1], tickformat=".2f")
return fig
def generate_visualisations(df):
feature_columns = [
col
for col in df.columns
if col in constants.FEATURE_COLUMNS
]
x = df[feature_columns]
y = df["Relative Position"]
model = LinearRegression()
model.fit(x, y)
plots = {}
plots["relative_pos_over_time"] = json.dumps(
generate_relative_position_over_time(df),
cls=plotly.utils.PlotlyJSONEncoder
)
df_line = df.melt(
id_vars="Date",
value_vars=["Absolute Position", "Number of Teams"],
var_name="Metric",
value_name="Value"
)
fig11 = px.line(
df_line,
x='Date',
y='Value',
color='Metric',
title='Absolute Position and Total Number of Teams Over Time'
)
plots["absolute_pos_over_time"] = json.dumps(
fig11, cls=plotly.utils.PlotlyJSONEncoder
)
# 2. Number of players vs position with regression line
fig2 = px.scatter(
df,
x="Number of Players",
y="Relative Position",
trendline="ols",
title="Players vs Position (%)",
)
fig2.update_xaxes(dtick=1)
plots["players_vs_position"] = json.dumps(
fig2, cls=plotly.utils.PlotlyJSONEncoder)
# 3. Player participation heatmap
df_players = df[constants.PLAYER_NAME_COLUMNS]
fig3 = px.imshow(
df_players.T,
labels=dict(x="Games", y="Player", color="Present"),
title="Player Participation Heatmap",
color_continuous_scale=constants.ATTENDANCE_COLORSCHEME,
zmin=0,
zmax=1,
aspect="auto"
)
fig3.update_coloraxes(
colorbar=dict(
tickvals=[0, 1],
ticktext=["Absent", "Present"],
lenmode="pixels",
len=300,
)
)
fig3.update_layout(
template="seaborn",
height=600,
yaxis=dict(
tickmode="array",
tickvals=list(range(len(df_players.columns))),
ticktext=df_players.columns
)
)
plots["player_participation"] = json.dumps(
fig3, cls=plotly.utils.PlotlyJSONEncoder)
# 4. Calendar view
plots["calendar"] = json.dumps(
generate_weekly_attendance_calendar(df),
cls=plotly.utils.PlotlyJSONEncoder
)
# 5. Coefficient bar chart
coefficients = pd.Series(model.coef_, index=x.columns).sort_values()
fig5 = px.bar(
coefficients,
orientation="h",
labels={"value": "Coefficient", "index": "Feature"},
title="Linear Regression Coefficients",
)
plots["coefficients"] = json.dumps(
fig5, cls=plotly.utils.PlotlyJSONEncoder)
return plots
@app.route("/")
def index():
df = get_data_frame("data.csv")
stats = generate_stats(df)
player_table = generate_player_table(df)
plots = generate_visualisations(df)
return render_template(
"index.html",
plots=plots,
stats=stats,
player_table=player_table
)
if __name__ == "__main__":
import plotly
app.run(debug=True)

32
src/constants.py Normal file
View File

@@ -0,0 +1,32 @@
PLAYER_NAME_COLUMNS = [
"Ciaran",
"Jay",
"Sam",
"Drew",
"Theo",
"Tom",
"Ellora",
"Chloe",
"Jamie",
"Christine"
]
FEATURE_COLUMNS = {
"Number of Players",
"Number of Teams",
"Points on Scattergories",
"Ciaran",
"Jay",
"Sam",
"Drew",
"Theo",
"Tom",
"Ellora",
"Chloe",
"Jamie",
"Christine"
}
ATTENDANCE_COLORSCHEME = [
[0, "#E4ECF6"], [1, "#636EFA"]
]

20
src/player_table.py Normal file
View File

@@ -0,0 +1,20 @@
from constants import PLAYER_NAME_COLUMNS
def generate_player_table(df):
header = [["Name", "Appearances", "Spent"]]
player_stats = [
(name, df[name].sum())
for name in df.columns
if name in PLAYER_NAME_COLUMNS
]
player_stats.sort(key=lambda x: x[1], reverse=True)
total = sum(n for name, n in player_stats)
body = [
[name, n, f"£{n * 3:.2f}"]
for name, n in player_stats
]
footer = [["Total", total, f"£{total * 3:.2f}"]]
return (header + body + footer)

53
src/stats.py Normal file
View File

@@ -0,0 +1,53 @@
import constants
def get_max_team_streak(dates):
max_streak = current_streak = 1
for i in range(1, len(dates)):
if (dates[i] - dates[i-1]).days == 7:
current_streak += 1
else:
current_streak = 1
max_streak = max(current_streak, max_streak)
# TODO: Show dates
return f"{max_streak} weeks"
def get_max_player_streak(df):
names = [col for col in df.columns if col in constants.PLAYER_NAME_COLUMNS]
max_streak = 1
max_name = names[0]
for name in names:
local_max = 1
current = 0
for attendance in df[name]:
if attendance:
current += 1
else:
local_max = max(local_max, current)
current = 0
local_max = max(local_max, current)
if local_max > max_streak:
max_streak = local_max
max_name = name
return f"{max_streak} ({max_name})"
def generate_stats(df):
stats = {}
n = len(df["Date"])
stats["Number of quizes played"] = n
avg_players = df["Number of Players"].sum() / n
stats["Average number of players"] = f"{avg_players:.2f}"
avg_teams = df["Number of Teams"].sum() / n
stats["Average number of teams"] = f"{avg_teams:.2f}"
p = df["Relative Position"].sum()*100 / n
stats["Average relative position"] = f"{p:.2f}th percentile"
stats["Max consecutive week streak"] = get_max_team_streak(df["Date"])
stats["Max consecutive player streak"] = get_max_player_streak(df)
return stats

80
src/templates/index.html Normal file
View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html>
<head>
<title>Pub Quiz Dashboard</title>
<script src="https://cdn.plot.ly/plotly-3.0.1.min.js" charset="utf-8"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="flex flex-col items-center">
<h1 class="text-center font-sans text-4xl p-10">📊 Pub Quiz Dashboard</h1>
<div class="w-3/4">
<h2 class="text-2xl mb-4">Stats</h2>
<div class="flex flex-col items-center">
<div class="rounded-md border border-black w-2/5 p-4 m-8">
<ul class="space-y-2">
{% for key, data in stats.items() %}
<li id="{{ key }}" class="flex justify-between">
<span>{{ key }}:</span>
<span class="flex-grow border-b border-dotted mx-2"></span>
<span>{{ data }}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<div class="w-3/4 flex flex-col items-center overflow-x-auto">
<table class="w-1/2 text-sm text-gray-700 border-separate border-spacing-0 rounded-xl bg-clip-border shadow-md">
<thead
class="bg-gray-100 text-xs font-semibold uppercase tracking-wider text-gray-700 border-b border-gray-300">
<tr>
{% for heading in player_table[0] %}
<th scope="col" class="px-6 py-4 w-1/3 border-b border-blue-grey-100 bg-blue-grey-50
{% if loop.index == 1 %} text-left
{% elif loop.index == 2 %} text-center
{% else %} text-right
{% endif %}
">
{{ heading }}
</th>
{% endfor %}
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for row in player_table[1:] %}
<tr class="bg-white">
{% for data in row %}
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 antialiased leading-normal border-b border-blue-grey-50
{% if loop.index == 1 %} text-left
{% elif loop.index == 2 %} text-center
{% else %} text-right
{% endif %}">
{{ data }}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="w-3/4">
<h2 class="text-2xl">Plots</h2>
{% for key, plot in plots.items() %}
<div class="m-8 bg-white p-8 rounded-md shadow-md">
<div id="{{ key }}"></div>
<script>
var figure = {{plot | safe }};
Plotly.newPlot("{{ key }}", figure.data, figure.layout);
</script>
</div>
{% endfor %}
</div>
</body>
</html>