Compare commits

...

25 Commits

Author SHA1 Message Date
326934ca9b fix sorting 2022-07-26 15:31:07 -05:00
077dc25a5d Fix update logic 2022-07-05 15:57:34 -05:00
69fe72512c ensure added user is added to round 2022-06-29 13:28:57 -05:00
cefa490f5f use ttl hash on score pull 2022-06-28 08:25:26 -05:00
28271623be better leaderboard formatting 2022-06-27 15:25:00 -05:00
5f350f1884 only label lines of currently ranked 2022-06-27 15:22:15 -05:00
ff54e2b053 tabulated presentation 2022-06-27 15:17:58 -05:00
244bed2c51 ensure historical tables show full length 2022-06-27 15:07:55 -05:00
7dea88a1b3 add leaderboard race lines 2022-06-27 14:18:11 -05:00
dd2ffced4c drop txn wrapper on raw sql 2022-06-24 20:15:23 -05:00
0427dbe6bd source dates from DB 2022-06-24 14:45:28 -05:00
87477820ec fix bulk insert 2022-06-24 07:01:50 -05:00
ff51dd7f8b verbose regex for cleaner human-reading 2022-06-22 15:50:52 -05:00
dc7e5abf4e fix add user, add sync round users from gsheets 2022-06-22 10:12:40 -05:00
228e0043d5 fix debug in docker cmd 2022-06-21 18:33:09 -05:00
7d57f00e5d memory management and loading animations 2022-06-21 18:31:05 -05:00
0090862359 atomicity fixes 2022-06-21 18:30:54 -05:00
3d17b6ef75 handle upcoming game, fix player selection 2022-06-21 17:16:06 -05:00
0dd78f1507 new cli utils 2022-06-21 16:14:32 -05:00
c689fcb4e7 add leaderboard 2022-06-20 11:37:57 -05:00
2f7b0af20c add user management cli 2022-06-20 11:13:17 -05:00
f5bf263635 data load improvements
* support given date for pull
* support load for user score not in game db yet
2022-06-20 10:33:49 -05:00
e94d0a7714 add multi-round display with dropdown 2022-06-20 10:05:33 -05:00
feeaba2c65 Skip update on non wordle golf days 2022-06-17 17:03:52 -05:00
48d42c2a4b Update __init__.py 2022-06-14 20:36:02 -05:00
9 changed files with 637 additions and 147 deletions

View File

@@ -2,9 +2,10 @@
docker build -t wordlinator:latest .
if [ "$1" = "--debug" ]; then
if [ "$1" == "--debug" ]; then
shift
docker run -d --rm -p 8050:8050 -e DEBUG=true -e DB_PORT -e DB_HOST -e DB_PASS "$@" --name wordlinator wordlinator:latest
echo "Running debug mode..."
docker run -d --rm -p 8050:8050 -e DASH_DEBUG=true -e DB_PORT -e DB_HOST -e DB_PASS "$@" --name wordlinator wordlinator:latest
else
docker run -d --rm -p 8050:8050 -e DB_PORT -e DB_HOST -e DB_PASS "$@" --name wordlinator wordlinator:latest
fi

View File

@@ -36,6 +36,10 @@ show-user = "wordlinator.app:sync_show_user"
show-missing = "wordlinator.app:sync_show_missing"
tweet-missing = "wordlinator.app:sync_tweet_missing"
db-load = "wordlinator.app:load_db_scores"
add-user = "wordlinator.app:sync_add_user"
create-round = "wordlinator.app:create_round"
copy-users = "wordlinator.app:copy_users"
gs-user-sync = "wordlinator.app:sync_gsheet_users"
[tool.mypy]
ignore_missing_imports = true

View File

@@ -84,7 +84,9 @@ def _save_db_scores(
db_holes = db.get_holes(game_no, ensure_all=True)
db_scores = db.get_scores(game_no)
db_scores_by_user = wordlinator.utils.scores.ScoreMatrix(db_scores).by_user()
db_scores_by_user = wordlinator.utils.scores.ScoreMatrix(db_scores).by_user(
usernames=list(scores.keys())
)
to_update = []
to_create = []
@@ -95,6 +97,7 @@ def _save_db_scores(
continue
db_user_match = [u for u in db_users if u.username == user]
if not db_user_match:
rich.print(f"[yellow]User {user} not in database, cannot add scores.")
continue
db_user = db_user_match[0]
twitter_score = twitter_scores.get(user, None)
@@ -115,6 +118,9 @@ def _save_db_scores(
async def main_update(
wordle_day: wordlinator.utils.WordleDay = wordlinator.utils.WORDLE_TODAY,
):
if not wordle_day.golf_hole:
rich.print(f"[yellow]{wordle_day.date} isn't a #WordleGolf day!")
exit()
sheets_client = wordlinator.sheets.SheetsClient(wordle_day=wordle_day)
today_scores = await get_scores(wordle_day=wordle_day)
@@ -166,6 +172,11 @@ def _get_day():
days.add_argument(
"--wordle-day", type=int, help="The wordle day number for the score report."
)
days.add_argument(
"--date",
type=wordlinator.utils.date_from_string,
help="a YYYY-MM-DD format date to pull a score report.",
)
args = parser.parse_args()
wordle_day = wordlinator.utils.WORDLE_TODAY
if args.wordle_day:
@@ -174,6 +185,8 @@ def _get_day():
wordle_day = wordlinator.utils.WordleDay.from_wordle_no(
wordle_day.wordle_no - args.days_ago
)
elif args.date:
wordle_day = wordlinator.utils.WordleDay.from_date(args.date)
return wordle_day
@@ -184,6 +197,115 @@ def load_db_scores():
_save_db_scores(wordle_day, scores)
def _add_user_args():
parser = argparse.ArgumentParser("add-user")
parser.add_argument("username", help="The user twitter handle or name.")
parser.add_argument(
"--no-check-twitter",
dest="check_twitter",
action="store_false",
default=True,
help="don't check Twitter for user's scores",
)
parser.add_argument(
"-g", "--games", nargs="*", help="The game/round number(s) to enroll the user."
)
parser.add_argument(
"-u",
"--unenroll-games",
nargs="*",
help="Game/round number(s) to unenroll the user.",
)
args = parser.parse_args()
return args
async def add_user(username, games=None, unenroll_games=None, check_twitter=True):
db = wordlinator.db.pg.WordleDb()
user = db.get_user(username)
if not user:
rich.print(f"[green]Creating user {username}")
user_id = None
if check_twitter:
twitter = wordlinator.twitter.TwitterClient()
user_id = await twitter.get_user_twitter_id(username)
if not user_id:
check_twitter = False
rich.print(
f"[yellow]No twitter ID found for {username}, "
"disabling twitter check"
)
user_id = user_id or f"{username}-NA"
user = db.add_user(username, user_id, check_twitter=check_twitter)
for round in games or []:
rich.print(f"[green]Adding {username} to round {round}")
db.add_user_to_round(username, round)
for round in unenroll_games or []:
rich.print(f"[green]Removing {username} from round {round}")
db.remove_user_from_round(username, round)
def create_round():
parser = argparse.ArgumentParser("create-round")
parser.add_argument("round_no", type=int, help="The round number to create.")
parser.add_argument(
"start_date",
type=wordlinator.utils.date_from_string,
help="the YYYY-mm-DD format date the round starts.",
)
args = parser.parse_args()
db = wordlinator.db.pg.WordleDb()
db.get_or_create_round(args.round_no, args.start_date)
db.create_round_holes(args.round_no)
def copy_users():
parser = argparse.ArgumentParser("copy-users")
parser.add_argument("from_round", type=int, help="The source round number.")
parser.add_argument("to_round", type=int, help="The destination round.")
args = parser.parse_args()
wordlinator.db.pg.WordleDb().copy_players_from_round(args.from_round, args.to_round)
async def pull_gsheets_users(round_no):
db = wordlinator.db.pg.WordleDb()
db_users = db.get_users_by_round(round_no=round_no)
round = db.get_or_create_round(round_no)
wordle_day = wordlinator.utils.WordleDay.from_date(round.end_date)
sheets = wordlinator.sheets.SheetsClient(wordle_day=wordle_day)
sheets_users = sheets.get_users()
for user in sheets_users:
db_match = [u for u in db_users if u.username == user]
if not db_match:
rich.print(f"[yellow]Adding {user} to Round {round_no}")
await add_user(user, games=[round_no])
for user in db_users:
if user.username not in sheets_users:
rich.print(f"[yellow]Removing {user.username} from Round {round_no}")
db.remove_user_from_round(user.username, round_no)
def sync_gsheet_users():
parser = argparse.ArgumentParser()
parser.add_argument("round_no", help="The round to sync.")
args = parser.parse_args()
asyncio.run(pull_gsheets_users(args.round_no))
def sync_add_user():
args = _add_user_args()
asyncio.run(
add_user(args.username, args.games, args.unenroll_games, args.check_twitter)
)
def sync_main():
wordle_day = _get_day()
asyncio.run(main(wordle_day=wordle_day))

View File

@@ -1,10 +1,9 @@
import datetime
import os
import typing
import peewee
import wordlinator.utils
db = peewee.PostgresqlDatabase(
os.getenv("DB_NAME", "wordlegolf"),
user=os.getenv("DB_USER", "wordlegolf"),
@@ -25,6 +24,9 @@ class User(BaseModel):
twitter_id = peewee.CharField(unique=True)
check_twitter = peewee.BooleanField(default=True)
def __repr__(self):
return f"<User {self.username}, Check Twitter: {self.check_twitter}>"
class Meta:
table_name = "user_tbl"
@@ -34,17 +36,30 @@ class Game(BaseModel):
game = peewee.IntegerField(null=False)
start_date = peewee.DateField(null=False)
def __repr__(self):
return f"<Game: Round {self.game}, Start {self.start_date}>"
@property
def end_date(self):
return self.start_date + datetime.timedelta(days=17)
class Player(BaseModel):
user_id = peewee.ForeignKeyField(User, "user_id", null=False)
game_id = peewee.ForeignKeyField(Game, "game_id", null=False)
class Meta:
primary_key = peewee.CompositeKey("user_id", "game_id")
class Hole(BaseModel):
hole_id = peewee.AutoField()
hole = peewee.IntegerField(null=False)
game_id = peewee.ForeignKeyField(Game, "game_id", null=False)
def __repr__(self):
return f"<Hole #{self.hole}>"
class Score(BaseModel):
score = peewee.IntegerField(null=False)
@@ -67,94 +82,175 @@ class WordleDb:
def get_users(self):
return list(User.select())
def get_user_id(self, username):
user = self.get_user(username)
return user.twitter_id if user else None
def get_users_by_round(self, round_no=None, round_id=None):
with db.atomic():
query = (
User.select(User, Player.user_id, Game.game)
.join(Player, on=(Player.user_id == User.user_id))
.join(Game, on=(Game.game_id == Player.game_id))
)
if round_no:
query = query.filter(Game.game == round_no)
elif round_id:
query = query.filter(Game.game_id == round_id)
return list(query)
def add_user(self, username, user_id):
return User.create(username=username, twitter_id=user_id)
def get_user_id(self, username):
with db.atomic():
user = self.get_user(username)
return user.twitter_id if user else None
def add_user(self, username, user_id, check_twitter=True):
with db.atomic():
return User.create(
username=username, twitter_id=user_id, check_twitter=check_twitter
)
def get_rounds(self):
with db.atomic():
return list(sorted(Game.select(), key=lambda d: d.start_date))
def get_or_create_round(self, round_no, start_date=None):
try:
return Game.get(Game.game == round_no)
except peewee.DoesNotExist:
start_date = (
start_date or wordlinator.utils.WORDLE_GOLF_ROUND_DATES[round_no - 1]
)
return Game.create(game=round_no, start_date=start_date)
with db.atomic():
try:
return Game.get(Game.game == round_no)
except peewee.DoesNotExist:
if not start_date:
raise ValueError(
f"Round {round_no} does not exist, "
"and no start_date provide to create it"
)
return Game.create(game=round_no, start_date=start_date)
def get_or_create_hole(self, round_no, hole_no):
round = self.get_or_create_round(round_no)
with db.atomic():
round = self.get_or_create_round(round_no)
try:
return Hole.get(Hole.hole == hole_no, Hole.game_id == round.game_id)
except peewee.DoesNotExist:
return Hole.create(hole=hole_no, game_id=round.game_id)
try:
return Hole.get(Hole.hole == hole_no, Hole.game_id == round.game_id)
except peewee.DoesNotExist:
return Hole.create(hole=hole_no, game_id=round.game_id)
def get_holes(self, round_no, ensure_all=False):
round = self.get_or_create_round(round_no)
if ensure_all:
self.create_round_holes(round_no)
return list(Hole.select().filter(game_id=round.game_id))
with db.atomic():
round = self.get_or_create_round(round_no)
if ensure_all:
self.create_round_holes(round_no)
return list(Hole.select().filter(game_id=round.game_id))
def create_round_holes(self, round_no):
for hole_no in range(1, 19):
self.get_or_create_hole(round_no, hole_no)
with db.atomic():
for hole_no in range(1, 19):
self.get_or_create_hole(round_no, hole_no)
def get_or_create_player_round(self, user_id, game_id):
with db.atomic():
try:
return Player.get(Player.user_id == user_id, Player.game_id == game_id)
except peewee.DoesNotExist:
return Player.create(user_id=user_id, game_id=game_id)
def add_user_to_round(self, username, round_no):
with db.atomic():
user = self.get_user(username)
if not user:
raise ValueError(f"No user found with username {username}")
round = self.get_or_create_round(round_no)
return self.get_or_create_player_round(user.user_id, round.game_id)
def remove_user_from_round(self, username, round_no):
with db.atomic():
user = self.get_user(username)
if not user:
raise ValueError(f"No user found with username {username}")
round = self.get_or_create_round(round_no)
try:
player = Player.get(user_id=user.user_id, game_id=round.game_id)
player.delete_instance()
except peewee.DoesNotExist:
return
def copy_players_from_round(self, from_round_no, to_round_no):
with db.atomic():
to_round = self.get_or_create_round(to_round_no)
for user in self.get_users_by_round(from_round_no):
self.get_or_create_player_round(user.user_id, to_round.game_id)
def add_score(self, username, game, hole, score):
if not score:
return
with db.atomic():
if not score:
return
user = self.get_user(username)
if not user:
raise ValueError(f'No Such User "{username}"')
hole = self.get_or_create_hole(game, hole)
user = self.get_user(username)
if not user:
raise ValueError(f'No Such User "{username}"')
hole = self.get_or_create_hole(game, hole)
try:
score_obj = Score.get(
Score.user_id == user.user_id,
Score.game_id == hole.game_id,
Score.hole_id == hole.hole_id,
)
score_obj.score = score
score_obj.save()
return score_obj
except peewee.DoesNotExist:
return Score.create(
score=score,
user_id=user.user_id,
game_id=hole.game_id,
hole_id=hole.hole_id,
)
try:
score_obj = Score.get(
Score.user_id == user.user_id,
Score.game_id == hole.game_id,
Score.hole_id == hole.hole_id,
)
score_obj.score = score
score_obj.save()
return score_obj
except peewee.DoesNotExist:
return Score.create(
score=score,
user_id=user.user_id,
game_id=hole.game_id,
hole_id=hole.hole_id,
)
def get_scores(self, round_no):
round = self.get_or_create_round(round_no)
res = (
Score.select(
Score,
Hole.hole,
User.username,
Player.game_id,
def get_scores(self, round_no=None, round_id=None):
with db.atomic():
if round_no:
round = self.get_or_create_round(round_no)
elif round_id:
round = Game.get_by_id(round_id)
else:
raise ValueError("Must provide Round Number or Round ID")
res = (
Score.select(
Score,
Hole.hole,
Hole.hole_id,
Game.game_id,
User.username,
User.user_id,
Player.game_id,
)
.join(Player, on=(Score.user_id == Player.user_id))
.switch(Score)
.join(Hole, on=(Score.hole_id == Hole.hole_id))
.join(Game, on=(Hole.game_id == Game.game_id))
.switch(Score)
.join(User, on=(Score.user_id == User.user_id))
.filter(Player.game_id == round.game_id)
.filter(Score.game_id == round.game_id)
)
.join(Player, on=(Score.user_id == Player.user_id))
.switch(Score)
.join(Hole, on=(Score.hole_id == Hole.hole_id))
.switch(Score)
.join(User, on=(Score.user_id == User.user_id))
.filter(Player.game_id == round.game_id)
.filter(Score.game_id == round.game_id)
)
return list(res)
return list(res) if res else []
def bulk_insert_scores(self, scores: typing.List[typing.Dict]):
with db.atomic():
with db.atomic() as txn:
for batch in peewee.chunked(scores, 50):
Score.insert_many(batch).execute()
def bulk_update_scores(self, scores: typing.List[Score]):
with db.atomic():
for score in scores:
score.save()
query_str = """UPDATE score
SET score = {score}, tweet_id = {tweet_id}
WHERE user_id = {user_id} AND game_id = {game_id} AND hole_id = {hole_id}"""
for score in scores:
query = query_str.format(
score=score.score,
tweet_id=score.tweet_id or "NULL",
user_id=score.user_id.user_id,
game_id=score.game_id.game_id,
hole_id=score.hole_id.hole_id,
)
db.execute_sql(query)
def get_users_without_score(self, round_no, hole_no, tweetable=True):
hole = self.get_or_create_hole(round_no, hole_no)

View File

@@ -17,7 +17,15 @@ import wordlinator.utils
BASE_URL = "https://api.twitter.com/2"
WORDLE_RE = re.compile(
r"Wordle(\w+)? (?P<number>\d+) (?P<score>[X\d])/6", re.IGNORECASE
r"""
Wordle # Line Leader
(\w+)? # etta factor (catches ...Golf, etc)
\s # single space
(?P<number>\d+) # game number
\s # single space
(?P<score>[X\d])/6 # score out of 6
""",
re.IGNORECASE | re.VERBOSE,
)
TOKEN = os.getenv("TWITTER_TOKEN")
@@ -133,17 +141,22 @@ class TwitterClient(httpx.AsyncClient):
async def get_user_by(self, username: str):
return await self.get(self.USER_PATH.format(username=username))
async def get_user_twitter_id(self, username: str):
user_id = None
twitter_user = await self.get_user_by(username)
if twitter_user.is_success:
user_id = twitter_user.json().get("data", {}).get("id", None)
return user_id
async def get_user_id(self, username: str):
db_user = self.db.get_user(username)
if db_user:
return db_user.twitter_id if db_user.check_twitter else False
else:
twitter_user = await self.get_user_by(username)
user_id = None
if twitter_user.is_success:
user_id = twitter_user.json().get("data", {}).get("id", None)
user_id = await self.get_user_twitter_id(username)
if user_id:
self.db.add_user(username, user_id)
self.db.add_user_to_round(username, self.wordle_day.golf_hole.game_no)
return user_id
def _start_timestamp(self):

View File

@@ -1,10 +1,21 @@
import argparse
import dataclasses
import datetime
import typing
import wordlinator.db.pg
WORDLE_DAY_ZERO = datetime.date(2021, 6, 19)
WORDLE_GOLF_ROUND_DATES = [datetime.date(2022, 5, 9), datetime.date(2022, 5, 31)]
WORDLE_GOLF_ROUNDS = wordlinator.db.pg.WordleDb().get_rounds()
def date_from_string(datestr: str):
try:
return datetime.date.fromisoformat(datestr)
except ValueError:
msg = "Invalid date string, expected format: YYYY-mm-DD"
raise argparse.ArgumentTypeError(msg)
@dataclasses.dataclass
@@ -14,10 +25,10 @@ class GolfHole:
@classmethod
def from_date(cls, date: datetime.date):
for game_no, start_date in enumerate(WORDLE_GOLF_ROUND_DATES, start=1):
hole_value = (date - start_date).days + 1
if 1 <= hole_value <= 18:
return cls(game_no=game_no, hole_no=hole_value)
for round in WORDLE_GOLF_ROUNDS:
if round.start_date <= date <= round.end_date:
hole_no = (date - round.start_date).days + 1
return cls(game_no=round.game, hole_no=hole_no)
return None
@@ -46,13 +57,17 @@ class WordleDay:
# Designed so that "today" will be the current date in CST
# Regardless of where the code is run
def get_wordle_today():
def get_today_central():
today = (
datetime.datetime.now(datetime.timezone.utc)
.astimezone(datetime.timezone(datetime.timedelta(hours=-5), name="US Central"))
.date()
)
return WordleDay.from_date(today)
return today
def get_wordle_today():
return WordleDay.from_date(get_today_central())
WORDLE_TODAY = get_wordle_today()

View File

@@ -1,4 +1,5 @@
import collections
import itertools
import typing
import wordlinator.db.pg
@@ -76,6 +77,15 @@ class UserRow(ScoreRow):
def golf_score(self) -> int:
return self.total - (self.count * 4)
@property
def progressive_score_list(self) -> typing.List[int]:
score_progress = list(
itertools.accumulate(
self.sorted_scores(), func=lambda t, e: t + (e.score - 4), initial=0
)
)[1:]
return score_progress
def sorted_scores(self):
yield from sorted(self._scores, key=lambda s: s.hole_id.hole)
@@ -144,17 +154,28 @@ class UserRow(ScoreRow):
)
else:
saved_score = score_match[0]
if saved_score.score != score or saved_score.tweet_id != tweet_id:
if saved_score.score != score or (
tweet_id and saved_score.tweet_id != tweet_id
):
saved_score.score = score
saved_score.tweet_id = tweet_id
if tweet_id:
saved_score.tweet_id = tweet_id
results["update"].append(saved_score)
return results
class ScoreMatrix(ScoreContainer):
def by_user(self):
return self.dict_by("user_id.username", UserRow)
def __init__(self, *args, usernames=None, **kwargs):
super().__init__(*args, **kwargs)
self.usernames = usernames or []
def by_user(self, usernames: typing.List[str] = []):
res = self.dict_by("user_id.username", UserRow)
for username in usernames or self.usernames:
if username not in res:
res[username] = UserRow([], username)
return res
def for_user(self, username):
user_scores = [s for s in self._scores if s.user_id.username == username]
@@ -181,3 +202,19 @@ class ScoreMatrix(ScoreContainer):
def user_rows(self, wordle_day):
hole_no = wordle_day.golf_hole.hole_no
return [u.user_row(hole_no=hole_no) for u in self.by_user().values()]
def top_by_day(self):
user_dict = {u: r.progressive_score_list for u, r in self.by_user().items()}
days = max(map(len, user_dict.values()))
rankings = collections.defaultdict(list)
for day_idx in range(days):
day_scores = {
u: v[day_idx] for u, v in user_dict.items() if len(v) >= day_idx + 1
}
tops = [(u, v) for u, v in sorted(day_scores.items(), key=lambda t: t[1])][
:20
]
for (user, score) in tops:
rankings[user].append((day_idx + 1, score))
return rankings

View File

@@ -1,3 +1,36 @@
from dash import dcc
###############
# Date Helper #
###############
def _date_range(game):
return f"{game.start_date} to {game.end_date}"
def get_date_dropdown(dates, wordle_day=None):
options = [
{"label": f"Round {d.game} ({_date_range(d)})", "value": d.game_id}
for d in dates
]
value = dates[-1].game_id
if wordle_day:
if wordle_day.golf_hole:
match = [d for d in dates if d.game == wordle_day.golf_hole.game_no]
else:
match = [d for d in dates[::-1] if d.end_date <= wordle_day.date]
value = match[0].game_id
return dcc.Dropdown(
id="round-selector-dropdown",
options=options,
value=value,
clearable=False,
)
######################
# Formatting Helpers #
######################

View File

@@ -1,6 +1,9 @@
import datetime
import collections
import functools
import math
import os
import pathlib
import re
import time
import dash
@@ -16,17 +19,24 @@ import wordlinator.utils
import wordlinator.utils.scores
import wordlinator.utils.web
TTL_TIME = 30 if os.getenv("DEBUG") else 90
LEADERBOARD_COUNT = 20
VALUE_RE = re.compile(r"\[(?P<value>-?\d+)\]")
###################
# Setup Functions #
###################
assets_dir = pathlib.Path(__file__).parent / "assets"
app = dash.Dash(
name="WordleGolf", title="#WordleGolf", assets_folder=str(assets_dir.resolve())
name="WordleGolf",
title="#WordleGolf",
assets_folder=str(assets_dir.resolve()),
suppress_callback_exceptions=True,
)
def get_ttl_hash(seconds=600):
def get_ttl_hash(seconds=TTL_TIME):
return round(time.time() / seconds)
@@ -36,31 +46,73 @@ long_callback_manager = dash.long_callback.DiskcacheLongCallbackManager(
)
@functools.lru_cache()
@functools.lru_cache(maxsize=1)
def _games_from_db(ttl_hash=None):
return db.WordleDb().get_rounds()
def games_from_db():
return _games_from_db()
@functools.lru_cache(maxsize=1)
def _wordle_today(ttl_hash=None):
today = wordlinator.utils.get_wordle_today()
if today.golf_hole:
return today
last_completed_round = [
dt for dt in wordlinator.utils.WORDLE_GOLF_ROUND_DATES[::-1] if dt <= today.date
game for game in games_from_db()[::-1] if game.start_date <= today.date
]
last_round_start = last_completed_round[0]
last_round_end = last_round_start + datetime.timedelta(days=17)
return wordlinator.utils.WordleDay.from_date(last_round_end)
last_round = last_completed_round[0]
return wordlinator.utils.WordleDay.from_date(last_round.end_date)
def wordle_today():
return _wordle_today(get_ttl_hash())
@functools.lru_cache()
def _scores_from_db(ttl_hash=None):
wordle_day = wordle_today()
return db.WordleDb().get_scores(wordle_day.golf_hole.game_no)
def round_wordle_day(round_id):
wt = wordle_today()
rounds = games_from_db()
matching_round = [r for r in rounds if r.game_id == round_id][0]
if matching_round.game == wt.golf_hole.game_no:
return wt
return wordlinator.utils.WordleDay.from_date(matching_round.end_date)
def scores_from_db():
return wordlinator.utils.scores.ScoreMatrix(_scores_from_db(get_ttl_hash()))
@functools.lru_cache(maxsize=3)
def _scores_from_db(round_id, ttl_hash=None):
wordle_db = db.WordleDb()
scores = wordle_db.get_scores(round_id=round_id)
users = wordle_db.get_users_by_round(round_id=round_id)
usernames = [u.username for u in users]
return wordlinator.utils.scores.ScoreMatrix(scores, usernames=usernames)
def scores_from_db(round_id):
return _scores_from_db(round_id, get_ttl_hash())
#######################
# Leaderboard helpers #
#######################
def get_leaderboard(round_id):
score_matrix = scores_from_db(round_id)
user_scores = score_matrix.by_user()
top_20 = dict(
list(sorted(user_scores.items(), key=lambda u: u[1].golf_score))[
:LEADERBOARD_COUNT
]
)
return dash.dash_table.DataTable(
[{"Name": k, "Score": v.golf_score} for k, v in top_20.items()],
style_as_list_view=True,
style_table={"width": "40%", "margin": "auto"},
style_cell={"textAlign": "center"},
)
#################
@@ -68,13 +120,20 @@ def scores_from_db():
#################
def get_scores():
score_matrix = scores_from_db()
table_rows = score_matrix.user_rows(wordle_today())
def _get_scores(round_id):
score_matrix = scores_from_db(round_id)
round_day = round_wordle_day(round_id)
table_rows = score_matrix.user_rows(round_day)
return table_rows
def get_scores(round_id):
round_day = round_wordle_day(round_id)
table_rows = _get_scores(round_id)
hole_columns = [
{"name": f"{i}", "id": f"{i}", "type": "text", "presentation": "markdown"}
for i in range(1, wordle_today().golf_hole.hole_no + 1)
for i in range(1, round_day.golf_hole.hole_no + 1)
]
columns = [
{"name": "Name", "id": "Name", "type": "text"},
@@ -103,6 +162,7 @@ def get_scores():
return dash.dash_table.DataTable(
table_rows,
columns,
id="user-scores-table",
style_table={
"width": "80%",
"margin": "auto",
@@ -111,11 +171,14 @@ def get_scores():
},
fixed_rows={"headers": True, "data": 0},
filter_action="native",
filter_options={"case": "insensitive"},
style_cell={"textAlign": "center"},
style_data={"width": "10%"},
style_as_list_view=True,
style_data_conditional=formatting,
sort_action="native",
sort_action="custom",
sort_mode="single",
sort_by=[{"column_id": "Name", "direction": "asc"}],
)
@@ -140,21 +203,24 @@ def _get_summary_rows(score_matrix):
return [totals, averages]
def _stats_dict():
score_matrix = scores_from_db()
def _stats_dict(round_id):
score_matrix = scores_from_db(round_id)
table_rows = [{"Score": k, **v} for k, v in score_matrix.score_breakdown().items()]
table_rows.extend(_get_summary_rows(score_matrix))
return table_rows
def get_daily_stats():
table_rows = _stats_dict()
def get_daily_stats(round_id):
table_rows = _stats_dict(round_id)
columns = [
{"name": n, "id": n}
for n in (
"Score",
*[f"{i}" for i in range(1, wordle_today().golf_hole.hole_no + 1)],
*[
f"{i}"
for i in range(1, round_wordle_day(round_id).golf_hole.hole_no + 1)
],
)
]
return dash.dash_table.DataTable(
@@ -185,8 +251,8 @@ SCORE_COLOR_DICT = {
}
def get_line_graph():
rows = _stats_dict()
def get_line_graph(round_id):
rows = _stats_dict(round_id)
figure = plotly.graph_objs.Figure()
total = [r for r in rows if r["Score"] == "Total"][0]
rows = [r for r in rows if r["Score"] not in ("Total", "Daily Average")]
@@ -215,6 +281,41 @@ def get_line_graph():
return dash.dcc.Graph(figure=figure)
#####################
# Line Race Helpers #
#####################
def line_race_graph(round_id):
score_matrix = scores_from_db(round_id)
tops_by_day = score_matrix.top_by_day()
round_day = round_wordle_day(round_id)
hole_no = round_day.golf_hole.hole_no
figure = plotly.graph_objs.Figure()
figure.update_yaxes(autorange="reversed")
figure.update_xaxes(tickmode="linear", tick0=1, dtick=1)
annotation_names = collections.defaultdict(list)
for name, entries in tops_by_day.items():
figure.add_trace(
plotly.graph_objs.Scatter(
name=name,
mode="lines+markers",
x=[e[0] for e in entries],
y=[e[1] for e in entries],
)
)
if entries[-1][0] == hole_no:
annotation_names[entries[-1]].append(name)
annotations = [
{"x": k[0], "y": k[1], "text": ", ".join(v)}
for k, v in annotation_names.items()
]
figure.update_layout(annotations=annotations)
return dash.dcc.Graph(figure=figure)
#############
# App Setup #
#############
@@ -223,52 +324,120 @@ app.layout = dash.html.Div(
children=[
dash.html.H1("#WordleGolf", style={"textAlign": "center"}, id="title"),
dash.html.Div(
[
dash.html.H2("User Scores", style={"textAlign": "center"}),
dash.html.Div("Loading...", id="user-scores"),
]
wordlinator.utils.web.get_date_dropdown(
games_from_db(), wordle_day=wordle_today()
),
id="round-selector",
style={"maxWidth": "300px"},
),
dash.html.Div(
[
dash.html.H2("Score Graph", style={"textAlign": "center"}),
dash.html.Div("Loading...", id="stats-graph"),
]
),
dash.html.Div(
[
dash.html.H2("Daily Stats", style={"textAlign": "center"}),
dash.html.Div("Loading...", id="daily-stats"),
]
dash.dcc.Tabs(
id="main-tabs",
value="leaderboard",
children=[
dash.dcc.Tab(label="Leaderboard", value="leaderboard"),
dash.dcc.Tab(label="Statistics", value="statistics"),
dash.dcc.Tab(label="User Scores", value="user-scores"),
],
),
dash.dcc.Loading(dash.html.Div(id="tab-content"), id="tab-content-loading"),
]
)
@app.long_callback(
output=dash.dependencies.Output("user-scores", "children"),
inputs=dash.dependencies.Input("title", "children"),
manager=long_callback_manager,
@app.callback(
dash.dependencies.Output("tab-content", "children"),
[
dash.dependencies.Input("main-tabs", "value"),
dash.dependencies.Input("round-selector-dropdown", "value"),
],
)
def get_scores_chart(_):
return get_scores()
def render_tab(tab, round_id):
if tab == "leaderboard":
return [
dash.html.Div(
[
dash.html.H2(
f"Leaderboard - Top {LEADERBOARD_COUNT}",
style={"textAlign": "center"},
),
dash.dcc.Loading(
id="leaderboard-race-loading",
children=dash.html.Div(
line_race_graph(round_id), id="leaderboard-race"
),
),
dash.dcc.Loading(
id="leaderboard-loading",
children=dash.html.Div(
get_leaderboard(round_id), id="leaderboard"
),
),
]
),
]
elif tab == "user-scores":
return [
dash.html.Div(
[
dash.html.H2("User Scores", style={"textAlign": "center"}),
dash.dcc.Loading(
id="user-scores-loading",
children=dash.html.Div(get_scores(round_id), id="user-scores"),
),
]
),
]
elif tab == "statistics":
return [
dash.html.Div(
[
dash.html.H2("Score Graph", style={"textAlign": "center"}),
dash.dcc.Loading(
id="stats-graph-loading",
children=dash.html.Div(
get_line_graph(round_id), id="stats-graph"
),
),
]
),
dash.html.Div(
[
dash.html.H2("Daily Stats", style={"textAlign": "center"}),
dash.dcc.Loading(
id="daily-stats-loading",
children=dash.html.Div(
get_daily_stats(round_id), id="daily-stats"
),
),
]
),
]
@app.long_callback(
output=dash.dependencies.Output("daily-stats", "children"),
inputs=dash.dependencies.Input("title", "children"),
manager=long_callback_manager,
@app.callback(
dash.dependencies.Output("user-scores-table", "data"),
dash.dependencies.Input("user-scores-table", "sort_by"),
dash.dependencies.State("user-scores-table", "data"),
)
def get_stats_chart(_):
return get_daily_stats()
def sort_scores(sort_by, data):
if not sort_by:
return data
sort_by = sort_by[0]
def _sort_val(entry):
col_id = sort_by["column_id"]
raw_val = entry.get(col_id)
if raw_val is None or raw_val == "":
return math.inf
if col_id == "Name":
return raw_val
if isinstance(raw_val, int) or raw_val.isdigit():
return int(raw_val)
match = VALUE_RE.match(raw_val).groupdict()["value"]
return int(match)
@app.long_callback(
output=dash.dependencies.Output("stats-graph", "children"),
inputs=dash.dependencies.Input("title", "children"),
manager=long_callback_manager,
)
def get_stats_graph(_):
return get_line_graph()
data = sorted(data, key=_sort_val, reverse=sort_by["direction"] == "desc")
return data
server = app.server