This commit is contained in:
John Gatward
2026-03-19 21:43:34 +00:00
parent f0a6dd85a6
commit 80a66bc44c
7 changed files with 588 additions and 317 deletions

View File

@@ -1,45 +1,62 @@
# AGENTS.md — Pub Quiz Dashboard # AGENTS.md — The Hope Pub Quiz Dashboard
## Overview ## Overview
Single-page Flask dashboard that reads `data.csv` (one row per quiz night) and renders statistics, a player table, and Plotly charts via a Jinja2 template. No database — the CSV is the sole data store. Single-page Flask dashboard that reads `data.csv` (one row per quiz night) and renders summary statistics, a player cost table, and five Plotly charts via a Jinja2 template. No database — the CSV is the sole data store.
## Running the App ## Running the App
```bash ```bash
uv sync # install dependencies (uses uv.lock / pyproject.toml) uv sync # install deps from uv.lock / pyproject.toml
cd src && python app.py # start Flask dev server at http://127.0.0.1:5000 PYTHONPATH=src python src/app.py # run from project root — resolves data.csv correctly
``` ```
> **Critical:** `data.csv` is read with a bare relative path (`"data.csv"`), so the app **must** be launched from the `src/` directory, where Flask's CWD resolves to the project root via the relative path `../data.csv` — actually, `data.csv` sits at project root and `src/` is the CWD, so Flask resolves it as `../data.csv` would fail. The app reads `data.csv` directly, so run from `src/` only after confirming the path resolves (currently works because Flask's CWD is `src/` and `data.csv` is read as a sibling — verify if moving files). > **Path gotcha:** `app.py` reads `data.csv` with a bare relative path, so the CWD must be the **project root**. `PYTHONPATH=src` puts `src/` on the import path so local modules resolve.
## Data Shape (`data.csv`) ## Data Shape (`data.csv`)
- **One row = one quiz night** - **One row = one quiz night**
- Columns: `Date` (DD/MM/YYYY), `Absolute Position`, `Relative Position` (float 01, where 0=1st, 1=last), `Number of Players`, `Number of Teams`, `Points on Scattergories`, then one binary column per player (1=attended, 0=absent). - Columns: `Date` (DD/MM/YYYY), `Absolute Position`, `Relative Position` (float 01, 0=1st/best, 1=last), `Number of Players`, `Number of Teams`, `Points on Scattergories`, then one binary column per player (1=attended, 0=absent).
- Date parsing is always `dayfirst=True`; the DataFrame is sorted by date on load. - Date parsing uses `dayfirst=True`; the DataFrame is sorted ascending by date on every load.
## Module Responsibilities ## Module Responsibilities
| File | Role | | File | Role |
|---|---| |---|---|
| `src/app.py` | Flask routes, all Plotly figure generation, data loading | | `src/app.py` | Flask route, five Plotly chart builders, data loading |
| `src/stats.py` | Summary statistics dict (streaks, averages) passed to template as `stats` | | `src/stats.py` | `generate_stats(df)``(stats_dict, highlights_list)` tuple |
| `src/player_table.py` | List-of-lists table `[header, ...rows..., footer]`; cost fixed at **£3/quiz** per player | | `src/player_table.py` | `generate_player_table(df)` → flat list-of-lists; cost hard-coded at **£3/quiz** |
| `src/constants.py` | Single source of truth for player names, regression features, and colour scheme | | `src/constants.py` | Player names, regression features, colour scheme, `ordinal(n)` helper |
| `src/templates/index.html` | Renders `stats` dict, `player_table` list, and `plots` dict of JSON-serialised Plotly figures | | `src/templates/index.html` | Renders `highlights` list, `stats` dict, `player_table`, and `plots` dict |
## Key Conventions ## Key Conventions
### Adding/Removing a Player ### Adding/Removing a Player
1. Update `constants.PLAYER_NAME_COLUMNS` (ordered list — controls display order). 1. Update `constants.PLAYER_NAME_COLUMNS` (ordered list — controls display order everywhere).
2. Update `constants.FEATURE_COLUMNS` (set — controls regression inputs). 2. Update `constants.FEATURE_COLUMNS` (set — controls which columns feed the regression model).
3. Add the new column to `data.csv` with `0`/`1` values. 3. Add the new binary column to `data.csv`.
### `generate_stats` Return Value
Returns a **tuple** `(stats, highlights)`:
- `highlights` — list of `{"label": str, "value": str, "detail": str}` dicts; rendered as 6 KPI cards.
- `stats` — plain `dict` of human-readable `label: value` pairs; rendered as a secondary list.
The `index()` route unpacks both: `stats, highlights = generate_stats(df)`.
### Plots Pipeline ### Plots Pipeline
Figures are built with Plotly in `app.py`, serialised to JSON with `plotly.utils.PlotlyJSONEncoder`, stored in a `plots` dict keyed by a snake_case name, and rendered client-side via `Plotly.newPlot("{{ key }}", figure.data, figure.layout)` in the template. The dict key becomes both the HTML `id` and the JS variable target — keep keys unique and valid as HTML ids. 1. Build a Plotly figure in `app.py` using `Relative Position` directly (or `Relative Position * 100` for percentile display), where **lower = better**.
2. Serialise: `json.dumps(fig, cls=plotly.utils.PlotlyJSONEncoder)`.
3. Store in the `plots` dict under a **snake_case key** (e.g. `"position_trend"`).
4. The template renders every entry automatically: `Plotly.newPlot("{{ key }}", ...)` — key is both the `<div id>` and JS target.
Current charts (in render order): `position_trend`, `player_impact`, `scattergories_vs_position`, `player_participation`, `calendar`.
### `player_table` Structure ### `player_table` Structure
A flat list-of-lists: index `[0]` = header row, `[1:-1]` = data rows, `[-1]` = footer row. The template iterates `player_table[1:]` for `<tbody>`, so the footer is rendered as a regular row — style it in CSS if distinction is needed. `[0]` = header row, `[1:-1]` = data rows sorted by appearances descending, `[-1]` = totals footer. The template uses `player_table[1:-1]` for `<tbody>` and `player_table[-1]` for `<tfoot>`.
### `Relative Position` Semantics ### `Relative Position` Convention
Lower is better (0 = first place). The trendline in `generate_relative_position_over_time` extrapolates towards `target_value = 0.08` (≈top 8%). Adjust this constant to change the goal projection. Raw data stores `Relative Position` (0=best). The dashboard keeps this convention everywhere: lower values are better in stats, tables, and chart labels. If a chart uses percentile text, it is `Relative Position * 100` (still lower = better).
### `ordinal(n)` Helper
Lives in `constants.py`. Returns e.g. `"1st"`, `"22nd"`, `"63rd"`. Import where needed: `from constants import ordinal`.
### Player Impact Chart
Shows average relative percentile when each player attends. Only players with **>= 3 appearances** are shown (`MIN_APPEARANCES = 3` in `generate_player_impact`). Green bar = below overall average (better); red = above (worse).
## Frontend ## Frontend
No build step. Tailwind CSS and Plotly are loaded from CDN in `index.html`. All styling is inline Tailwind utility classes. No build step. Tailwind CSS (`cdn.tailwindcss.com`) and Plotly (`cdn.plot.ly/plotly-3.0.1.min.js`) loaded from CDN. Charts use `{ responsive: true, displayModeBar: false }` config.

View File

@@ -1,22 +1,24 @@
Date,Absolute Position,Relative Position,Number of Players,Number of Teams,Points on Scattergories,Ciaran,Jay,Sam,Drew,Theo,Tom,Ellora,Chloe,Jamie,Christine Date,Absolute Position,Relative Position,Number of Players,Number of Teams,Points on Scattergories,Ciaran,Jay,Sam,Drew,Theo,Tom,Ellora,Chloe,Jamie,Christine,Mide
17/03/2025,9,0.692,2,13,10,1,1,0,0,0,0,0,0,0,0 17/03/2025,9,0.692,2,13,10,1,1,0,0,0,0,0,0,0,0,0
24/03/2025,14,0.933,2,15,4,1,1,0,0,0,0,0,0,0,0 24/03/2025,14,0.933,2,15,4,1,1,0,0,0,0,0,0,0,0,0
31/03/2025,8,0.444,4,18,7,1,1,1,1,0,0,0,0,0,0 31/03/2025,8,0.444,4,18,7,1,1,1,1,0,0,0,0,0,0,0
07/04/2025,9,0.563,2,16,6,1,1,0,0,0,0,0,0,0,0 07/04/2025,9,0.563,2,16,6,1,1,0,0,0,0,0,0,0,0,0
28/04/2025,9,0.529,3,17,5,1,1,1,0,0,0,0,0,0,0 28/04/2025,9,0.529,3,17,5,1,1,1,0,0,0,0,0,0,0,0
26/05/2025,6,0.5,2,12,8,0,1,0,0,1,0,0,0,0,0 26/05/2025,6,0.5,2,12,8,0,1,0,0,1,0,0,0,0,0,0
02/06/2025,12,0.857,3,14,4,1,1,1,0,0,0,0,0,0,0 02/06/2025,12,0.857,3,14,4,1,1,1,0,0,0,0,0,0,0,0
16/06/2025,6,0.545,3,11,6,1,1,1,0,0,0,0,0,0,0 16/06/2025,6,0.545,3,11,6,1,1,1,0,0,0,0,0,0,0,0
30/06/2025,5,0.833,4,6,8,1,1,1,0,0,1,0,0,0,0 30/06/2025,5,0.833,4,6,8,1,1,1,0,0,1,0,0,0,0,0
14/07/2025,5,0.625,3,8,8,1,1,0,0,0,0,1,0,0,0 14/07/2025,5,0.625,3,8,8,1,1,0,0,0,0,1,0,0,0,0
21/07/2025,4,0.5,2,8,9,1,1,0,0,0,0,0,0,0,0 21/07/2025,4,0.5,2,8,9,1,1,0,0,0,0,0,0,0,0,0
28/07/2025,6,0.375,4,16,4,1,1,1,0,0,0,1,0,0,0 28/07/2025,6,0.375,4,16,4,1,1,1,0,0,0,1,0,0,0,0
04/08/2025,4,0.4,3,10,11,1,1,0,0,0,0,1,0,0,0 04/08/2025,4,0.4,3,10,11,1,1,0,0,0,0,1,0,0,0,0
15/09/2025,4,0.333,4,12,5,1,1,1,0,0,0,1,0,0,0 15/09/2025,4,0.333,4,12,5,1,1,1,0,0,0,1,0,0,0,0
22/09/2025,5,0.417,3,12,5,1,0,1,0,0,1,0,0,0,0 22/09/2025,5,0.417,3,12,5,1,0,1,0,0,1,0,0,0,0,0
29/09/2025,8,0.727,2,11,5,0,1,0,1,0,0,0,0,0,0 29/09/2025,8,0.727,2,11,5,0,1,0,1,0,0,0,0,0,0,0
24/11/2025,8,0.889,3,9,6,1,1,0,0,0,0,1,0,0,0 24/11/2025,8,0.889,3,9,6,1,1,0,0,0,0,1,0,0,0,0
05/01/2026,4,0.5,4,8,6,1,1,0,0,0,0,0,1,1,0 05/01/2026,4,0.5,4,8,6,1,1,0,0,0,0,0,1,1,0,0
26/01/2026,7,0.583,2,12,10,1,1,0,0,0,0,0,0,0,0 26/01/2026,7,0.583,2,12,10,1,1,0,0,0,0,0,0,0,0,0
02/02/2026,7,0.7,2,10,5,1,1,0,0,0,0,0,0,0,0 02/02/2026,7,0.7,2,10,5,1,1,0,0,0,0,0,0,0,0,0
16/02/2026,9,0.5,3,18,6,0,1,0,1,0,0,0,0,0,1 16/02/2026,9,0.5,3,18,6,0,1,0,1,0,0,0,0,0,1,0
23/02/2026,6,0.6,2,10,10,1,1,0,0,0,0,0,0,0,0,0
09/03/2026,6,1,5,6,10,1,1,1,0,0,0,0,1,0,0,1
1 Date Absolute Position Relative Position Number of Players Number of Teams Points on Scattergories Ciaran Jay Sam Drew Theo Tom Ellora Chloe Jamie Christine Mide
2 17/03/2025 9 0.692 2 13 10 1 1 0 0 0 0 0 0 0 0 0
3 24/03/2025 14 0.933 2 15 4 1 1 0 0 0 0 0 0 0 0 0
4 31/03/2025 8 0.444 4 18 7 1 1 1 1 0 0 0 0 0 0 0
5 07/04/2025 9 0.563 2 16 6 1 1 0 0 0 0 0 0 0 0 0
6 28/04/2025 9 0.529 3 17 5 1 1 1 0 0 0 0 0 0 0 0
7 26/05/2025 6 0.5 2 12 8 0 1 0 0 1 0 0 0 0 0 0
8 02/06/2025 12 0.857 3 14 4 1 1 1 0 0 0 0 0 0 0 0
9 16/06/2025 6 0.545 3 11 6 1 1 1 0 0 0 0 0 0 0 0
10 30/06/2025 5 0.833 4 6 8 1 1 1 0 0 1 0 0 0 0 0
11 14/07/2025 5 0.625 3 8 8 1 1 0 0 0 0 1 0 0 0 0
12 21/07/2025 4 0.5 2 8 9 1 1 0 0 0 0 0 0 0 0 0
13 28/07/2025 6 0.375 4 16 4 1 1 1 0 0 0 1 0 0 0 0
14 04/08/2025 4 0.4 3 10 11 1 1 0 0 0 0 1 0 0 0 0
15 15/09/2025 4 0.333 4 12 5 1 1 1 0 0 0 1 0 0 0 0
16 22/09/2025 5 0.417 3 12 5 1 0 1 0 0 1 0 0 0 0 0
17 29/09/2025 8 0.727 2 11 5 0 1 0 1 0 0 0 0 0 0 0
18 24/11/2025 8 0.889 3 9 6 1 1 0 0 0 0 1 0 0 0 0
19 05/01/2026 4 0.5 4 8 6 1 1 0 0 0 0 0 1 1 0 0
20 26/01/2026 7 0.583 2 12 10 1 1 0 0 0 0 0 0 0 0 0
21 02/02/2026 7 0.7 2 10 5 1 1 0 0 0 0 0 0 0 0 0
22 16/02/2026 9 0.5 3 18 6 0 1 0 1 0 0 0 0 0 1 0
23 23/02/2026 6 0.6 2 10 10 1 1 0 0 0 0 0 0 0 0 0
24 09/03/2026 6 1 5 6 10 1 1 1 0 0 0 0 1 0 0 1

View File

@@ -2,10 +2,10 @@ from flask import Flask, render_template
import pandas as pd import pandas as pd
import plotly.express as px import plotly.express as px
import plotly.graph_objects as go import plotly.graph_objects as go
import plotly.utils
import statsmodels.api as sm import statsmodels.api as sm
import numpy as np import numpy as np
import datetime import datetime
from sklearn.linear_model import LinearRegression
import json import json
from stats import generate_stats from stats import generate_stats
from player_table import generate_player_table from player_table import generate_player_table
@@ -13,6 +13,9 @@ import constants
app = Flask(__name__) app = Flask(__name__)
# ---------------------------------------------------------------------------
# Data loading
# ---------------------------------------------------------------------------
def get_data_frame(filename): def get_data_frame(filename):
df = pd.read_csv(filename) df = pd.read_csv(filename)
@@ -22,236 +25,383 @@ def get_data_frame(filename):
def build_hovertext(df, attendance_columns): def build_hovertext(df, attendance_columns):
return df[attendance_columns].apply( present = [c for c in attendance_columns if c in df.columns]
lambda row: ", ".join( return df[present].apply(
[ lambda row: ", ".join(p for p in present if row[p] == 1) or "No attendance",
player axis=1,
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 # Charts
df["Year"] = df["Date"].dt.isocalendar().year # ---------------------------------------------------------------------------
df["Week"] = df["Date"].dt.isocalendar().week
attendee_columns = [ def generate_position_trend(df):
col for col in df.columns if col not in { """
"Date", "Relative Position", "Number of Players", Line chart of relative position percentile over time (lower is better).
"Number of Teams", "Attendees", "Year", "Week", "Year-Week" Overlays a 5-game rolling average and an extended OLS trendline
} projected to the top-8th-percentile target.
] """
df = df.copy()
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) df["Date_ordinal"] = df["Date"].map(pd.Timestamp.toordinal)
df["Relative Percentile"] = df["Relative Position"] * 100
df["Rolling Avg (5)"] = df["Relative Percentile"].rolling(5, min_periods=1).mean()
df["Attendees"] = build_hovertext(df, constants.PLAYER_NAME_COLUMNS)
X = sm.add_constant(df["Date_ordinal"]) X = sm.add_constant(df["Date_ordinal"])
y = df["Relative Position"] model = sm.OLS(df["Relative Percentile"], X).fit()
model = sm.OLS(y, X).fit()
df["BestFit"] = model.predict(X)
intercept = model.params["const"] intercept = model.params["const"]
slope = model.params["Date_ordinal"] slope = model.params["Date_ordinal"]
target_value = 0.08 target_percentile = 8.0
min_ord = df["Date_ordinal"].min()
max_ord = df["Date_ordinal"].max()
predicted_ordinal = (target_value - intercept) / slope predicted_ordinal = None
if slope < 0:
predicted_ordinal = (target_percentile - intercept) / slope
min_ordinal = df["Date_ordinal"].min() end_ord = max(max_ord, predicted_ordinal) if predicted_ordinal and predicted_ordinal > max_ord else max_ord
max_ordinal = df["Date_ordinal"].max()
if predicted_ordinal > max_ordinal: extended_ords = np.linspace(min_ord, end_ord, 200)
extended_ordinals = np.linspace(min_ordinal, predicted_ordinal, 100) extended_percentile = intercept + slope * extended_ords
else: extended_dates = [datetime.date.fromordinal(int(x)) for x in extended_ords]
extended_ordinals = np.linspace(min_ordinal, max_ordinal, 100)
extended_bestfit = intercept + slope * extended_ordinals fig = go.Figure()
extended_dates = [datetime.date.fromordinal( fig.add_scatter(
int(x)) for x in extended_ordinals] x=df["Date"],
y=df["Relative Percentile"],
mode="lines+markers",
name="Result",
line=dict(color="#1e3a8a", width=1.5),
marker=dict(size=6, color="#1e3a8a"),
customdata=df["Attendees"],
hovertemplate="<b>%{x|%d %b %Y}</b><br>Relative percentile: %{y:.0f}th<br>Squad: %{customdata}<extra></extra>",
)
df["Attendees"] = build_hovertext(df, constants.PLAYER_NAME_COLUMNS) fig.add_scatter(
x=df["Date"],
fig = px.line( y=df["Rolling Avg (5)"],
df, mode="lines",
x="Date", name="5-Game Avg",
y="Relative Position", line=dict(color="#f59e0b", width=2.5),
title="Quiz Position Over Time with Extended Trendline", hovertemplate="<b>%{x|%d %b %Y}</b><br>5-Game Avg: %{y:.0f}%<extra></extra>",
hover_data={"Attendees": True}
) )
fig.add_scatter( fig.add_scatter(
x=extended_dates, x=extended_dates,
y=extended_bestfit, y=extended_percentile,
mode="lines", mode="lines",
name="Extended Trendline", name="Trend",
line=dict(dash="dot", color="red") line=dict(dash="dot", color="#dc2626", width=1.5),
hoverinfo="skip",
) )
fig.update_yaxes(range=[0, 1], tickformat=".2f") if predicted_ordinal and predicted_ordinal > max_ord:
target_date = datetime.date.fromordinal(int(predicted_ordinal))
fig.add_annotation(
x=target_date,
y=target_percentile,
text=f"8th percentile target: {target_date.strftime('%b %Y')}",
showarrow=True,
arrowhead=2,
font=dict(size=11, color="#dc2626"),
)
fig.add_hline(
y=50,
line_dash="dot",
line_color="#9ca3af",
annotation_text="50th percentile",
annotation_position="bottom right",
)
fig.update_layout(
title="Relative Position Over Time",
xaxis_title="Date",
yaxis=dict(title="Relative Position Percentile (lower is better)", range=[0, 100], ticksuffix="th"),
template="plotly_white",
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
hovermode="x unified",
margin=dict(t=60, b=40),
)
return fig return fig
def generate_visualisations(df): def generate_player_impact(df):
feature_columns = [ """
col Horizontal bar chart: average relative position percentile when each player attends.
for col in df.columns Only shows players with >= 3 appearances.
if col in constants.FEATURE_COLUMNS Green bar = lower than overall average (better); red = higher (worse).
"""
MIN_APPEARANCES = 3
overall_percentile = df["Relative Position"].mean() * 100
rows = []
for name in constants.PLAYER_NAME_COLUMNS:
if name not in df.columns:
continue
attended = df[df[name] == 1]
n = len(attended)
if n >= MIN_APPEARANCES:
percentile = attended["Relative Position"].mean() * 100
rows.append({"Player": name, "Relative Percentile": round(percentile, 1), "Games": n})
if not rows:
return go.Figure()
impact_df = pd.DataFrame(rows).sort_values("Relative Percentile", ascending=True)
colors = [
"#16a34a" if p <= overall_percentile else "#dc2626"
for p in impact_df["Relative Percentile"]
] ]
x = df[feature_columns]
y = df["Relative Position"]
model = LinearRegression() fig = go.Figure(
model.fit(x, y) go.Bar(
x=impact_df["Relative Percentile"],
plots = {} y=impact_df["Player"],
orientation="h",
plots["relative_pos_over_time"] = json.dumps( marker_color=colors,
generate_relative_position_over_time(df), text=[
cls=plotly.utils.PlotlyJSONEncoder f"{constants.ordinal(round(p))} ({g} games)"
for p, g in zip(impact_df["Relative Percentile"], impact_df["Games"])
],
textposition="outside",
hovertemplate="<b>%{y}</b><br>Avg relative percentile: %{x:.1f}th<extra></extra>",
)
) )
df_line = df.melt( fig.add_vline(
id_vars="Date", x=overall_percentile,
value_vars=["Absolute Position", "Number of Teams"], line_dash="dot",
var_name="Metric", line_color="#6b7280",
value_name="Value" annotation_text=f"Overall avg ({constants.ordinal(round(overall_percentile))})",
) annotation_position="top right",
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 fig.update_layout(
fig2 = px.scatter( title="Who Helps Most - Avg. Relative Position When Attending",
xaxis=dict(
title="Avg. Relative Position Percentile (lower is better)",
range=[0, 100],
ticksuffix="th",
),
yaxis=dict(title="", autorange="reversed"),
template="plotly_white",
showlegend=False,
height=max(300, len(rows) * 52),
margin=dict(t=60, b=40, r=20),
)
return fig
def generate_scattergories_chart(df):
"""
Scatter of Scattergories points vs relative position percentile with OLS trendline.
Negative slope = scoring more in Scattergories correlates with better finish.
"""
df = df.copy()
df["Relative Percentile"] = df["Relative Position"] * 100
fig = px.scatter(
df, df,
x="Number of Players", x="Points on Scattergories",
y="Relative Position", y="Relative Percentile",
trendline="ols", trendline="ols",
title="Players vs Position (%)", title="Scattergories vs Relative Position",
labels={
"Points on Scattergories": "Scattergories Points",
"Relative Percentile": "Relative Position Percentile (lower is better)",
},
hover_data={"Relative Percentile": ":.1f"},
) )
fig2.update_xaxes(dtick=1) fig.update_traces(
plots["players_vs_position"] = json.dumps( marker=dict(color="#1e3a8a", size=9, opacity=0.8),
fig2, cls=plotly.utils.PlotlyJSONEncoder) selector=dict(mode="markers"),
)
fig.update_traces(
line=dict(color="#dc2626", dash="dot", width=2),
selector=dict(type="scatter", mode="lines"),
)
fig.update_layout(
template="plotly_white",
yaxis=dict(ticksuffix="th", range=[0, 100]),
xaxis=dict(dtick=1),
margin=dict(t=60, b=40),
)
return fig
# 3. Player participation heatmap
df_players = df[constants.PLAYER_NAME_COLUMNS] def generate_player_participation(df):
fig3 = px.imshow( """Heatmap of which player attended which game."""
player_cols = [c for c in constants.PLAYER_NAME_COLUMNS if c in df.columns]
df_players = df[player_cols]
fig = px.imshow(
df_players.T, df_players.T,
labels=dict(x="Games", y="Player", color="Present"), labels=dict(x="Game", y="Player", color="Attended"),
title="Player Participation Heatmap", title="Player Attendance by Game",
color_continuous_scale=constants.ATTENDANCE_COLORSCHEME, color_continuous_scale=constants.ATTENDANCE_COLORSCHEME,
zmin=0, zmin=0,
zmax=1, zmax=1,
aspect="auto" aspect="auto",
) )
fig3.update_coloraxes( fig.update_coloraxes(
colorbar=dict( colorbar=dict(
tickvals=[0, 1], tickvals=[0, 1],
ticktext=["Absent", "Present"], ticktext=["Absent", "Present"],
lenmode="pixels", lenmode="pixels",
len=300, len=200,
) )
) )
fig3.update_layout( fig.update_layout(
template="seaborn", template="plotly_white",
height=600, height=max(300, len(player_cols) * 40 + 100),
yaxis=dict( yaxis=dict(
tickmode="array", tickmode="array",
tickvals=list(range(len(df_players.columns))), tickvals=list(range(len(player_cols))),
ticktext=df_players.columns ticktext=player_cols,
),
margin=dict(t=60, b=40),
) )
) return fig
plots["player_participation"] = json.dumps(
fig3, cls=plotly.utils.PlotlyJSONEncoder)
# 4. Calendar view
plots["calendar"] = json.dumps( def generate_weekly_attendance_calendar(df):
generate_weekly_attendance_calendar(df), """Compact weekly attendance heatmap (13-column grid blocks per year)."""
cls=plotly.utils.PlotlyJSONEncoder df = df.copy()
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",
"Absolute Position",
"Points on Scattergories",
}
]
df["Attended"] = (df[attendee_columns].sum(axis=1) > 0).astype(int)
weekly = df.groupby(["Year", "Week"])["Attended"].max().reset_index()
# Build a compact matrix: 13 columns, 4 (or 5 for week 53) rows per year.
all_years = sorted(df["Year"].unique())
max_week = int(df["Week"].max())
rows_per_year = 5 if max_week == 53 else 4
grid_rows = []
for year in all_years:
for block in range(1, rows_per_year + 1):
for col in range(1, 14):
week = (block - 1) * 13 + col
if week > 53:
continue
grid_rows.append(
{
"Year": year,
"Block": block,
"Col": col,
"Week": week,
}
) )
# 5. Coefficient bar chart calendar = pd.DataFrame(grid_rows).merge(
coefficients = pd.Series(model.coef_, index=x.columns).sort_values() weekly,
fig5 = px.bar( on=["Year", "Week"],
coefficients, how="left",
orientation="h",
labels={"value": "Coefficient", "index": "Feature"},
title="Linear Regression Coefficients",
) )
plots["coefficients"] = json.dumps( calendar["Attended"] = calendar["Attended"].fillna(0).astype(int)
fig5, cls=plotly.utils.PlotlyJSONEncoder)
return plots y_labels = []
for year in all_years:
for block in range(1, rows_per_year + 1):
start = (block - 1) * 13 + 1
end = min(block * 13, 53)
y_labels.append(f"{year} · W{start}-{end}")
calendar["RowLabel"] = calendar.apply(
lambda r: f"{int(r['Year'])} · W{(int(r['Block']) - 1) * 13 + 1}-{min(int(r['Block']) * 13, 53)}",
axis=1,
)
z_matrix = []
text_matrix = []
for label in y_labels:
row = calendar[calendar["RowLabel"] == label].sort_values("Col")
z_matrix.append(row["Attended"].tolist())
text_matrix.append([f"ISO week {int(w)}" for w in row["Week"]])
fig = go.Figure(
data=go.Heatmap(
x=list(range(1, 14)),
y=y_labels,
z=z_matrix,
text=text_matrix,
hovertemplate="%{y}<br>Column %{x}<br>%{text}<br>Attended: %{z}<extra></extra>",
colorscale=constants.ATTENDANCE_COLORSCHEME,
zmin=0,
zmax=1,
showscale=False,
xgap=3,
ygap=3,
)
)
fig.update_layout(
title="Weekly Attendance Calendar",
xaxis=dict(title="Week Column (1-13)", tickmode="linear", dtick=1),
yaxis_title="Year / Week Range",
template="plotly_white",
height=180 + len(y_labels) * 34,
margin=dict(t=60, b=40),
)
return fig
# ---------------------------------------------------------------------------
# Visualisation bundle
# ---------------------------------------------------------------------------
def generate_visualisations(df):
enc = plotly.utils.PlotlyJSONEncoder
return {
"position_trend": json.dumps(generate_position_trend(df), cls=enc),
"player_impact": json.dumps(generate_player_impact(df), cls=enc),
"scattergories_vs_position": json.dumps(generate_scattergories_chart(df), cls=enc),
"player_participation": json.dumps(generate_player_participation(df), cls=enc),
"calendar": json.dumps(generate_weekly_attendance_calendar(df), cls=enc),
}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.route("/") @app.route("/")
def index(): def index():
df = get_data_frame("data.csv") df = get_data_frame("data.csv")
stats = generate_stats(df) stats, highlights = generate_stats(df)
player_table = generate_player_table(df) player_table = generate_player_table(df)
plots = generate_visualisations(df) plots = generate_visualisations(df)
return render_template( return render_template(
"index.html", "index.html",
plots=plots, plots=plots,
stats=stats, stats=stats,
player_table=player_table highlights=highlights,
player_table=player_table,
) )
if __name__ == "__main__": if __name__ == "__main__":
import plotly
app.run(debug=True) app.run(debug=True)

View File

@@ -1,3 +1,13 @@
def ordinal(n):
"""Return an ordinal string for integer n, e.g. 1 → '1st', 22 → '22nd'."""
n = int(n)
if 11 <= (n % 100) <= 13:
suffix = "th"
else:
suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
return f"{n}{suffix}"
PLAYER_NAME_COLUMNS = [ PLAYER_NAME_COLUMNS = [
"Ciaran", "Ciaran",
"Jay", "Jay",
@@ -8,7 +18,8 @@ PLAYER_NAME_COLUMNS = [
"Ellora", "Ellora",
"Chloe", "Chloe",
"Jamie", "Jamie",
"Christine" "Christine",
"Mide",
] ]
FEATURE_COLUMNS = { FEATURE_COLUMNS = {
@@ -24,9 +35,10 @@ FEATURE_COLUMNS = {
"Ellora", "Ellora",
"Chloe", "Chloe",
"Jamie", "Jamie",
"Christine" "Christine",
"Mide",
} }
ATTENDANCE_COLORSCHEME = [ ATTENDANCE_COLORSCHEME = [
[0, "#E4ECF6"], [1, "#636EFA"] [0, "#E4ECF6"], [1, "#1e3a8a"]
] ]

View File

@@ -1,20 +1,25 @@
from constants import PLAYER_NAME_COLUMNS from constants import PLAYER_NAME_COLUMNS, ordinal
def generate_player_table(df): def generate_player_table(df):
header = [["Name", "Appearances", "Spent"]] header = [["Player", "Appearances", "Avg. Relative Percentile", "Spent"]]
player_stats = []
for name in PLAYER_NAME_COLUMNS:
if name in df.columns:
attended = df[df[name] == 1]
n = len(attended)
if n > 0:
avg_rel = attended["Relative Position"].mean()
player_stats.append((name, n, avg_rel))
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) player_stats.sort(key=lambda x: x[1], reverse=True)
total = sum(n for name, n in player_stats) total = sum(n for _, 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) body = [
[name, n, ordinal(round(avg_rel * 100)), f"£{n * 3:.2f}"]
for name, n, avg_rel in player_stats
]
footer = [["Total", total, "", f"£{total * 3:.2f}"]]
return header + body + footer

View File

@@ -1,53 +1,92 @@
import constants import constants
from constants import ordinal
def get_max_team_streak(dates): def _max_team_streak(dates):
max_streak = current_streak = 1 max_streak = current_streak = 1
for i in range(1, len(dates)): for i in range(1, len(dates)):
if (dates[i] - dates[i-1]).days == 7: if (dates[i] - dates[i - 1]).days == 7:
current_streak += 1 current_streak += 1
else: else:
current_streak = 1 current_streak = 1
max_streak = max(current_streak, max_streak) max_streak = max(max_streak, current_streak)
return max_streak
# TODO: Show dates
return f"{max_streak} weeks"
def get_max_player_streak(df): def _max_player_streak(df):
names = [col for col in df.columns if col in constants.PLAYER_NAME_COLUMNS] names = [col for col in df.columns if col in set(constants.PLAYER_NAME_COLUMNS)]
max_streak = 1 max_streak, max_name = 1, names[0]
max_name = names[0]
for name in names: for name in names:
local_max = 1 local_max = current = 0
current = 0 for att in df[name]:
for attendance in df[name]: if att:
if attendance:
current += 1 current += 1
else: else:
local_max = max(local_max, current) local_max = max(local_max, current)
current = 0 current = 0
local_max = max(local_max, current) local_max = max(local_max, current)
if local_max > max_streak: if local_max > max_streak:
max_streak = local_max max_streak, max_name = local_max, name
max_name = name return max_streak, max_name
return f"{max_streak} ({max_name})"
def generate_stats(df): def generate_stats(df):
stats = {} n = len(df)
n = len(df["Date"]) avg_rel = df["Relative Position"].mean()
last5_avg = df.tail(5)["Relative Position"].mean()
top_half = int((df["Relative Position"] < 0.5).sum())
stats["Number of quizes played"] = n best_rel_idx = df["Relative Position"].idxmin()
avg_players = df["Number of Players"].sum() / n best_abs = int(df.loc[best_rel_idx, "Absolute Position"])
stats["Average number of players"] = f"{avg_players:.2f}" best_teams = int(df.loc[best_rel_idx, "Number of Teams"])
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 team_streak = _max_team_streak(list(df["Date"]))
player_streak, streak_player = _max_player_streak(df)
improving = last5_avg < avg_rel
form_arrow = "" if improving else ""
form_label = "improving" if improving else "declining"
highlights = [
{
"label": "Quizzes Played",
"value": str(n),
"detail": f"Since {df['Date'].iloc[0].strftime('%b %Y')}",
},
{
"label": "Avg. Relative Percentile",
"value": ordinal(round(avg_rel * 100)),
"detail": "Lower = better finishing position",
},
{
"label": "Best Finish",
"value": f"{ordinal(best_abs)} place",
"detail": f"Out of {best_teams} teams",
},
{
"label": "Recent Form",
"value": ordinal(round(last5_avg * 100)),
"detail": f"{form_arrow} {form_label} vs overall (lower is better)",
},
{
"label": "Top-Half Finishes",
"value": str(top_half),
"detail": f"{top_half / n * 100:.0f}% of all games",
},
{
"label": "Best Streak",
"value": f"{team_streak} wks",
"detail": "Consecutive weeks attended",
},
]
stats = {
"Top-half finishes": f"{top_half} of {n} ({top_half / n * 100:.0f}%)",
"Average teams per night": f"{df['Number of Teams'].mean():.1f}",
"Average squad size": f"{df['Number of Players'].mean():.1f} players",
"Average Scattergories score": f"{df['Points on Scattergories'].mean():.1f} pts",
"Best Scattergories score": f"{int(df['Points on Scattergories'].max())} pts",
"Longest individual streak": f"{player_streak} weeks ({streak_player})",
}
return stats, highlights

View File

@@ -1,79 +1,125 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<title>Pub Quiz Dashboard</title> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>The Hope Pub Quiz</title>
<script src="https://cdn.plot.ly/plotly-3.0.1.min.js" charset="utf-8"></script> <script src="https://cdn.plot.ly/plotly-3.0.1.min.js" charset="utf-8"></script>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
</head> </head>
<body class="flex flex-col items-center"> <body class="bg-gray-50 min-h-screen text-gray-900 antialiased">
<h1 class="text-center font-sans text-4xl p-10">📊 Pub Quiz Dashboard</h1>
<div class="w-3/4"> <!-- ── Header ─────────────────────────────────────────────────────────── -->
<h2 class="text-2xl mb-4">Stats</h2> <header class="bg-blue-900 text-white shadow-lg">
<div class="flex flex-col items-center"> <div class="max-w-6xl mx-auto px-4 sm:px-8 py-8 sm:py-10">
<div class="rounded-md border border-black w-2/5 p-4 m-8"> <p class="text-blue-300 text-xs font-semibold tracking-widest uppercase mb-2">Performance Dashboard</p>
<ul class="space-y-2"> <h1 class="text-3xl sm:text-4xl font-bold tracking-tight">🍺 The Hope Pub Quiz</h1>
{% for key, data in stats.items() %} <p class="text-blue-300 mt-2 text-sm">Trying to convince ourselves we're not stupid</p>
<li id="{{ key }}" class="flex justify-between"> </div>
<span>{{ key }}:</span> </header>
<span class="flex-grow border-b border-dotted mx-2"></span>
<span>{{ data }}</span> <main class="max-w-6xl mx-auto px-4 sm:px-8 py-10 space-y-14">
</li>
<!-- ── KPI Cards ───────────────────────────────────────────────────── -->
<section>
<h2 class="text-xs font-semibold tracking-widest uppercase text-gray-400 mb-5">At a Glance</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
{% for h in highlights %}
<div class="bg-white rounded-xl border border-gray-100 shadow-sm p-5 flex flex-col gap-1">
<span class="text-3xl font-bold text-blue-900 leading-none">{{ h.value }}</span>
<span class="text-sm font-medium text-gray-700 mt-2">{{ h.label }}</span>
<span class="text-xs text-gray-400 leading-snug">{{ h.detail }}</span>
</div>
{% endfor %} {% endfor %}
</ul>
</div> </div>
</section>
<!-- ── Stats + Squad Table ─────────────────────────────────────────── -->
<section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
<!-- Secondary stats -->
<div class="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
<h2 class="text-xs font-semibold tracking-widest uppercase text-gray-400 mb-5">More Stats</h2>
<dl class="divide-y divide-gray-50">
{% for key, value in stats.items() %}
<div class="flex justify-between items-baseline py-3 first:pt-0 last:pb-0">
<dt class="text-sm text-gray-500">{{ key }}</dt>
<dd class="text-sm font-semibold text-gray-900 ml-4 text-right">{{ value }}</dd>
</div> </div>
{% endfor %}
</dl>
</div> </div>
<div class="w-3/4 flex flex-col items-center overflow-x-auto"> <!-- Squad table -->
<table class="w-1/2 text-sm text-gray-700 border-separate border-spacing-0 rounded-xl bg-clip-border shadow-md"> <div class="bg-white rounded-xl border border-gray-100 shadow-sm p-6 overflow-x-auto">
<thead <h2 class="text-xs font-semibold tracking-widest uppercase text-gray-400 mb-5">Squad</h2>
class="bg-gray-100 text-xs font-semibold uppercase tracking-wider text-gray-700 border-b border-gray-300"> <table class="w-full text-sm">
<tr> <thead>
<tr class="border-b-2 border-gray-100">
{% for heading in player_table[0] %} {% 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 <th class="pb-3 text-xs font-semibold text-gray-400 uppercase tracking-wider
{% if loop.index == 1 %} text-left {% if loop.index == 1 %}text-left{% else %}text-right{% endif %}">
{% elif loop.index == 2 %} text-center
{% else %} text-right
{% endif %}
">
{{ heading }} {{ heading }}
</th> </th>
{% endfor %} {% endfor %}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200"> <tbody class="divide-y divide-gray-50">
{% for row in player_table[1:] %} {% for row in player_table[1:-1] %}
<tr class="bg-white"> <tr class="hover:bg-gray-50 transition-colors">
{% for data in row %} {% 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 <td class="py-3
{% if loop.index == 1 %} text-left {% if loop.index == 1 %}text-left font-medium text-gray-800{% else %}text-right text-gray-500{% endif %}">
{% elif loop.index == 2 %} text-center
{% else %} text-right
{% endif %}">
{{ data }} {{ data }}
</td> </td>
{% endfor %} {% endfor %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
<tfoot>
<tr class="border-t-2 border-gray-200">
{% for data in player_table[-1] %}
<td class="pt-3 font-bold text-gray-700
{% if loop.index == 1 %}text-left{% else %}text-right{% endif %}">
{{ data }}
</td>
{% endfor %}
</tr>
</tfoot>
</table> </table>
</div> </div>
<div class="w-3/4"> </section>
<h2 class="text-2xl">Plots</h2>
<!-- ── Charts ──────────────────────────────────────────────────────── -->
<section>
<h2 class="text-xs font-semibold tracking-widest uppercase text-gray-400 mb-5">Charts</h2>
<div class="space-y-8">
{% for key, plot in plots.items() %} {% for key, plot in plots.items() %}
<div class="m-8 bg-white p-8 rounded-md shadow-md"> <div class="bg-white rounded-xl border border-gray-100 shadow-sm p-4 sm:p-6">
<div id="{{ key }}"></div> <div id="{{ key }}"></div>
<script> <script>
var figure = {{plot | safe }}; (function () {
Plotly.newPlot("{{ key }}", figure.data, figure.layout); var figure = {{ plot | safe }};
Plotly.newPlot("{{ key }}", figure.data, figure.layout, { responsive: true, displayModeBar: false });
})();
</script> </script>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</section>
</main>
<!-- ── Footer ─────────────────────────────────────────────────────────── -->
<footer class="mt-16 border-t border-gray-200 bg-white">
<div class="max-w-6xl mx-auto px-4 sm:px-8 py-5 flex flex-col sm:flex-row justify-between items-center gap-2 text-xs text-gray-400">
<span>The Hope Pub Quiz Dashboard</span>
<span>{{ highlights[0].value }} quizzes · {{ plots | length }} charts</span>
</div>
</footer>
</body> </body>