From f120336f1d93c638c8508cdf8d249dd87ddbaad0 Mon Sep 17 00:00:00 2001 From: Jack Jackson Date: Fri, 23 Aug 2024 06:16:04 -0700 Subject: [PATCH] Add cursory "biggest movers" stats Also adds rudimentary testing framework for seeding a database from a given `.db` SQLite file. Probably extract this for general use! --- app/routers/base.py | 4 +- app/routers/stats.py | 74 +++++++++++++++++- app/templates/main.html | 50 ++++++++++++ .../sqlite-database-snapshots/empty_db.db | Bin 0 -> 28672 bytes .../sqlite-database-snapshots/populated_db.db | Bin 0 -> 53248 bytes tests/routers/test_stats.py | 72 +++++++++++++++++ tests/sql/test_crud.py | 1 + 7 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 test-data/sqlite-database-snapshots/empty_db.db create mode 100644 test-data/sqlite-database-snapshots/populated_db.db create mode 100644 tests/routers/test_stats.py diff --git a/app/routers/base.py b/app/routers/base.py index 31c5d36..8f4e4d8 100644 --- a/app/routers/base.py +++ b/app/routers/base.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse from ..sql import crud +from ..routers.stats import top_movers from ..templates import jinja_templates, _jsonify from ..sql.database import get_db @@ -11,8 +12,9 @@ html_router = APIRouter(include_in_schema=False, default_response_class=HTMLResp @html_router.get("/") def main(request: Request, db=Depends(get_db)): games = crud.get_games(db=db) + movers = top_movers(db=db) return jinja_templates.TemplateResponse( - request, "/main.html", {"games": _jsonify(games)} + request, "/main.html", {"games": _jsonify(games), "top_movers": movers} ) diff --git a/app/routers/stats.py b/app/routers/stats.py index 62547ec..4ce10e8 100644 --- a/app/routers/stats.py +++ b/app/routers/stats.py @@ -1,5 +1,6 @@ from collections import defaultdict -from datetime import datetime, MINYEAR +from datetime import datetime, timedelta, MINYEAR +from heapq import nlargest, nsmallest from typing import Optional from fastapi import APIRouter, Depends, Request @@ -76,6 +77,77 @@ def stats_graph_api( } +# As with many APIs, this is a candidate for parallelization if desired - +# could key by deck_id, then in parallel get scores over the time period for that deck. +# But performance isn't likely to be a big issue! +@api_router.get("/top_movers") +def top_movers( + lookback_in_days: int = 7, + number_of_movers: int = 3, + db=Depends(get_db), +): + # TODO - this will error-out on an empty database + date_of_latest_game = ( + db.query(models.Game.date) + .order_by(models.Game.date.desc()) + .limit(1) + .first() + ._tuple()[0] + ) + beginning_of_lookback = date_of_latest_game - timedelta(days=lookback_in_days) + + # TODO - this mostly duplicates logic from `stats_graph_api`. Extract? + row_number_column = ( + func.row_number() + .over( + partition_by=[models.Deck.name, models.Game.date], + order_by=models.EloScore.id.desc(), + ) + .label("row_number") + ) + sub_query = ( + db.query( + models.Deck.id, models.Deck.name, models.EloScore.score, models.Game.date + ) + .outerjoin(models.EloScore, models.Deck.id == models.EloScore.deck_id) + .join(models.Game, models.EloScore.after_game_id == models.Game.id) + .add_column(row_number_column) + .subquery() + ) + scores = ( + db.query(sub_query) + .filter(sub_query.c.row_number == 1) + .order_by(sub_query.c.date) + .all() + ) + score_tracker = defaultdict(dict) + # First, get the score-per-deck at the start and end of the time period + for score in scores: + if score.date <= beginning_of_lookback: + score_tracker[score.id]["start_score"] = score.score + score_tracker[score.id]["latest_score"] = score.score + # Technically we don't need to _keep_ adding this (as it won't change for a given deck_id) - but, until/unless + # this logic is parallelized, there's no efficient way for the algorithm to know that it's operating on a deck + # that's already been seen once before + score_tracker[score.id]["name"] = score.name + # Then, find biggest movers + calculateds = [ + { + "deck_id": deck_id, + "name": score_tracker[deck_id]["name"], + "start": score_tracker[deck_id]["start_score"], + "end": score_tracker[deck_id]["latest_score"], + "diff": score_tracker[deck_id]["latest_score"] + - score_tracker[deck_id]["start_score"], + } + for deck_id in score_tracker + ] + return { + "positive": nlargest(number_of_movers, calculateds, key=lambda x: x["diff"]), + "negative": nsmallest(number_of_movers, calculateds, key=lambda x: x["diff"]), + } + + @html_router.get("/graph") def stats_graph(request: Request, db=Depends(get_db)): return jinja_templates.TemplateResponse(request, "stats/graph.html") diff --git a/app/templates/main.html b/app/templates/main.html index 4f6b942..5e6a45d 100644 --- a/app/templates/main.html +++ b/app/templates/main.html @@ -4,8 +4,58 @@ {% block head %} {{ super() }} + + {% endblock %} {% block content %}

Welcome to EDH ELO! Click "Games" above to see the list of Games, or "Record New Game" in the top-right to record a game

+
+

Biggest recent movers

?Logic:
Find the date of the latest game
Look back a period of 7 days from that date
Calculate score differential between those two dates for all decks
Rank by that

TODO - add a dedicated "biggest movers" page under "Stats" where anchor dates and number-of-top-movers can be specified
+

Positive

+
    + {% for positive_mover in top_movers['positive'] %} +
  1. {{ positive_mover['name'] }} - +{{ positive_mover['diff'] }} ({{ positive_mover['start'] }} -> {{ positive_mover['end'] }})
  2. + {% endfor %} +
+ +

Negative

+
    + {% for negative_mover in top_movers['negative'] %} +
  1. {{ negative_mover['name'] }} - {{ negative_mover['diff'] }} ({{ negative_mover['start'] }} -> {{ negative_mover['end'] }})
  2. + {% endfor %} +
+
{% endblock %} \ No newline at end of file diff --git a/test-data/sqlite-database-snapshots/empty_db.db b/test-data/sqlite-database-snapshots/empty_db.db new file mode 100644 index 0000000000000000000000000000000000000000..da1ed6b3c2e2eef5f3fce2349f8fc5e7c6af5ef5 GIT binary patch literal 28672 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