From 9ddc8663a967abe770332b0a2139117c5007f8a3 Mon Sep 17 00:00:00 2001 From: Anthony Correa Date: Fri, 15 Aug 2025 11:06:27 -0500 Subject: [PATCH] feat: improve draft admin UI, draft state sync, and styling Major refactor of Draft admin and participant Websocket state sync Use consistent state dict serialization in DraftStateManager (to_dict, dict-like access, etc.) Always include up-to-date participants and draft status in sync payloads Draft phase/order summary now sent as objects instead of calling .get_summary() UI/UX updates: Updated DraftAdmin.jsx: Connects DraftParticipant panel for real-time participant state Centralizes phase advance, bidding, and sync controls Moves phase selector into a dedicated panel Refine markup/extends in room_admin.dj.html (use block body, fix root data attribute) Minor fixes to DraftCountdownClock.jsx to robustly handle NaN time CSS/layout: Refactor .draft-participant styling to .wrapper within #draft-participant-root and #draft-admin-root for better responsive layout and code clarity Server code: Simplify draft consumer/manager state interaction, drop unused cache keys, update order determination and phase management, and ensure DRY status object responses Small code style and consistency cleanups Misc: Add debugpy launch task in code-workspace and clean workspace JSON (style/consistency) Minor formatting and error handling improvements --- boxofficefantasy.code-workspace | 39 +++++---- boxofficefantasy/templates/base.dj.html | 34 ++++---- draft/consumers.py | 79 ++++++++----------- draft/state.py | 75 ++++++++++++------ draft/templates/draft/room_admin.dj.html | 8 +- frontend/src/apps/draft/admin/DraftAdmin.jsx | 71 ++++++++--------- .../apps/draft/common/DraftCountdownClock.jsx | 2 +- .../draft/participant/DraftParticipant.jsx | 2 +- frontend/src/scss/styles.scss | 50 ++++++------ 9 files changed, 193 insertions(+), 167 deletions(-) diff --git a/boxofficefantasy.code-workspace b/boxofficefantasy.code-workspace index 5ed7551..50fae6b 100644 --- a/boxofficefantasy.code-workspace +++ b/boxofficefantasy.code-workspace @@ -7,6 +7,18 @@ "launch": { "version": "0.2.0", "configurations": [ + { + "name": "Debug current file with debugpy", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": false, + "args": [], + "env": { + "PYTHONPATH": "${workspaceFolder}" + } + }, { "name": "Run Django Server", "type": "debugpy", @@ -22,7 +34,7 @@ "type": "debugpy", "request": "launch", "module": "uvicorn", - "args": ["boxofficefantasy_project.asgi:application", "--reload",], + "args": ["boxofficefantasy_project.asgi:application", "--reload"], "django": true, "console": "integratedTerminal", "envFile": "${workspaceFolder}/.env" @@ -32,10 +44,7 @@ "type": "node", "request": "launch", "runtimeExecutable": "npm", - "args": [ - "run", - "dev" - ], + "args": ["run", "dev"], "cwd": "${workspaceFolder}/frontend", "console": "integratedTerminal", "internalConsoleOptions": "neverOpen" @@ -62,7 +71,11 @@ "compounds": [ { "name": "Django + Chrome + Webpack", - "configurations": ["Run Django Server", "Launch Chrome", "Start Webpack Dev Server"], + "configurations": [ + "Run Django Server", + "Launch Chrome", + "Start Webpack Dev Server" + ], "type": "compound" } ] @@ -152,7 +165,7 @@ "editor.defaultFormatter": "ms-python.black-formatter" }, "[django-html]": { - "editor.defaultFormatter": "monosans.djlint", + "editor.defaultFormatter": "monosans.djlint" }, "emmet.includeLanguages": { "django-html": "html" @@ -161,15 +174,13 @@ "*.dj.html": "django-html" }, "files.exclude": { - "**/__pycache__":true, - ".venv":false + "**/__pycache__": true, + ".venv": false }, - "auto-close-tag.activationOnLanguage": [ - "django-html" - ], + "auto-close-tag.activationOnLanguage": ["django-html"], "terminal.integrated.env.osx": { - "VSCODE_HISTFILE":"${workspaceFolder}/.venv/.term_history" - }, + "VSCODE_HISTFILE": "${workspaceFolder}/.venv/.term_history" + } // "html.autoClosingTags": true, } } diff --git a/boxofficefantasy/templates/base.dj.html b/boxofficefantasy/templates/base.dj.html index a8967f5..cd08ada 100644 --- a/boxofficefantasy/templates/base.dj.html +++ b/boxofficefantasy/templates/base.dj.html @@ -19,7 +19,7 @@ - + - {% endblock breadcrumbs %} - {% block content %}{% endblock content %} - {% endblock body %} + {{ crumb.label }} + {% else %} + {{ crumb.label }} + {% endif %} + + {% endfor %} + + {% endif %} + + {% endblock breadcrumbs %} + {% block content %} + {% endblock content %} - - - + {% endblock body %} + + + diff --git a/draft/consumers.py b/draft/consumers.py index 15b80ea..c1bdebf 100644 --- a/draft/consumers.py +++ b/draft/consumers.py @@ -4,7 +4,6 @@ from django.core.exceptions import PermissionDenied from boxofficefantasy.models import League, Season from boxofficefantasy.views import parse_season_slug from draft.models import DraftSession, DraftSessionParticipant -from django.core.cache import cache import asyncio from django.contrib.auth.models import User from draft.constants import ( @@ -12,7 +11,7 @@ from draft.constants import ( DraftPhase, DraftGroupChannelNames, ) -from draft.state import DraftCacheKeys, DraftStateManager +from draft.state import DraftStateManager from typing import Any import logging @@ -24,7 +23,6 @@ import random class DraftConsumerBase(AsyncJsonWebsocketConsumer): group_names: DraftGroupChannelNames - cache_keys: DraftCacheKeys draft_state: DraftStateManager user: User @@ -39,7 +37,6 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer): ) self.group_names = DraftGroupChannelNames(draft_hashid) - self.cache_keys = DraftCacheKeys(draft_hashid) self.draft_state = DraftStateManager(self.draft_session) self.user = self.scope["user"] @@ -49,8 +46,8 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer): { "type": "direct.message", "subtype": DraftMessage.PARTICIPANT_JOIN_REJECT, - "payload":{"current_user": self.user.username} - } + "payload": {"current_user": self.user.username}, + }, ) await self.close() await self.channel_layer.group_send( @@ -58,7 +55,7 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer): { "type": "broadcast.admin", "subtype": DraftMessage.PARTICIPANT_JOIN_REJECT, - "payload":{"user": self.user.username} + "payload": {"user": self.user.username}, }, ) return @@ -80,7 +77,13 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer): { "type": "direct.message", "subtype": DraftMessage.STATUS_SYNC_INFORM, - "payload": self.get_draft_status(), + "payload": { + **self.draft_state, + "user": self.user.username, + "participants": [ + user.username for user in self.draft_participants + ], + }, }, ) await self.channel_layer.send( @@ -121,11 +124,7 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer): # === Methods === def get_draft_status(self) -> dict[str, Any]: - return { - **self.draft_state.get_summary(), - "user": self.user.username, - "participants": [user.username for user in self.draft_participants], - } + return # === DB Access === @database_sync_to_async @@ -133,8 +132,8 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer): draft_session_id = DraftSession.decode_id(draft_session_id_hashed) if draft_session_id: draft_session = DraftSession.objects.select_related( - "season", "season__league", "settings" - ).get(pk=draft_session_id) + "season", "season__league", "settings", + ).prefetch_related("participants").get(pk=draft_session_id) else: raise Exception() @@ -178,13 +177,13 @@ class DraftAdminConsumer(DraftConsumerBase): { "type": "broadcast.session", "subtype": DraftMessage.DRAFT_INDEX_ADVANCE_CONFIRM, - "payload": self.draft_state.get_summary(), + "payload": {**self.draft_state}, }, ) if event_type == DraftMessage.NOMINATION_SUBMIT_REQUEST: - movie_id = content.get('payload',{}).get('movie_id') - user = content.get('payload',{}).get('user') + movie_id = content.get("payload", {}).get("movie_id") + user = content.get("payload", {}).get("user") self.draft_state.start_nomination(movie_id) await self.channel_layer.group_send( self.group_names.session, @@ -192,25 +191,23 @@ class DraftAdminConsumer(DraftConsumerBase): "type": "broadcast.session", "subtype": DraftMessage.NOMINATION_CONFIRM, "payload": { - "current_movie": self.draft_state.get_summary()['current_movie'], - "nominating_participant": user - } - } + "current_movie": self.draft_state[ + "current_movie" + ], + "nominating_participant": user, + }, + }, ) if event_type == DraftMessage.BID_START_REQUEST: - self.draft_state.start_timer() + + self.draft_state.start_bidding() await self.channel_layer.group_send( self.group_names.session, { "type": "broadcast.session", "subtype": DraftMessage.BID_START_INFORM, - "payload": { - "current_movie": self.draft_state.get_summary()['current_movie'], - "bidding_duration": self.draft_state.settings.bidding_duration, - "bidding_timer_end": self.draft_state.get_timer_end(), - "bidding_timer_start": self.draft_state.get_timer_start() - } - } + "payload": self.get_draft_status(), + }, ) def should_accept_user(self): @@ -229,9 +226,7 @@ class DraftAdminConsumer(DraftConsumerBase): ) async def determine_draft_order(self): - draft_order = self.draft_state.determine_draft_order(self.draft_participants) - self.draft_state.draft_index = 0 - await self.set_draft_phase(DraftPhase.DETERMINE_ORDER) + self.draft_state.determine_draft_order() next_picks = self.draft_state.next_picks(include_current=True) await self.channel_layer.group_send( @@ -239,12 +234,7 @@ class DraftAdminConsumer(DraftConsumerBase): { "type": "broadcast.session", "subtype": DraftMessage.ORDER_DETERMINE_CONFIRM, - "payload": { - "draft_order": draft_order, - "draft_index": self.draft_state.draft_index, - "current_pick": next_picks[0], - "next_picks": next_picks[1:] - }, + "payload": {**self.draft_state}, }, ) @@ -311,7 +301,7 @@ class DraftParticipantConsumer(DraftConsumerBase): async def receive_json(self, content): await super().receive_json(content) - event_type = content.get('type') + event_type = content.get("type") if event_type == DraftMessage.NOMINATION_SUBMIT_REQUEST: await self.channel_layer.group_send( self.group_names.admin, @@ -319,13 +309,12 @@ class DraftParticipantConsumer(DraftConsumerBase): "type": "broadcast.admin", "subtype": event_type, "payload": { - "movie_id": content.get('payload',{}).get('id'), - "user": content.get('payload',{}).get('user') - } - } + "movie_id": content.get("payload", {}).get("id"), + "user": content.get("payload", {}).get("user"), + }, + }, ) - # === Broadcast handlers === async def broadcast_participant(self, event): diff --git a/draft/state.py b/draft/state.py index cc893f9..cf9dd1a 100644 --- a/draft/state.py +++ b/draft/state.py @@ -10,6 +10,10 @@ from dataclasses import dataclass from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple import random +class DraftStateException(Exception): + """Raised when an action is not allowed due to the current draft state or phase.""" + pass + class DraftCacheKeys: def __init__(self, id): self.prefix = f"draft:{id}" @@ -79,59 +83,62 @@ class DraftStateManager: def __init__(self, session: DraftSession): self.session_id = session.hashid self.cache = cache - self.keys = DraftCacheKeys(self.session_id) - self._initial_phase = self.cache.get(self.keys.phase, DraftPhase.WAITING.value) + self.cache_keys = DraftCacheKeys(self.session_id) + self._initial_phase = self.cache.get(self.cache_keys.phase, DraftPhase.WAITING.value) self.settings = session.settings + self.participants = list(session.participants.all()) # === Phase Management === @property def phase(self) -> str: - return str(self.cache.get(self.keys.phase, self._initial_phase)) + return str(self.cache.get(self.cache_keys.phase, self._initial_phase)) @phase.setter def phase(self, new_phase: DraftPhase): - self.cache.set(self.keys.phase, new_phase.value) + self.cache.set(self.cache_keys.phase, new_phase.value) # === Connected Users === @property def connected_participants(self) -> list[str]: - return json.loads(self.cache.get(self.keys.connected_users) or "[]") + return json.loads(self.cache.get(self.cache_keys.connected_users) or "[]") def connect_participant(self, username: str): users = set(self.connected_participants) users.add(username) - self.cache.set(self.keys.connected_users, json.dumps(list(users))) + self.cache.set(self.cache_keys.connected_users, json.dumps(list(users))) def disconnect_participant(self, username: str): users = set(self.connected_participants) users.discard(username) - self.cache.set(self.keys.connected_users, json.dumps(list(users))) + self.cache.set(self.cache_keys.connected_users, json.dumps(list(users))) # === Draft Order === @property def draft_order(self): - return json.loads(self.cache.get(self.keys.draft_order,"[]")) + return json.loads(self.cache.get(self.cache_keys.draft_order,"[]")) @draft_order.setter def draft_order(self, draft_order: list[str]): if not isinstance(draft_order, list): return - self.cache.set(self.keys.draft_order,json.dumps(draft_order)) + self.cache.set(self.cache_keys.draft_order,json.dumps(draft_order)) - def determine_draft_order(self, users: list[User]): + def determine_draft_order(self) -> List[User]: + self.phase = DraftPhase.DETERMINE_ORDER + self.draft_index = 0 draft_order = random.sample( - users, len(users) + self.participants, len(self.participants) ) self.draft_order = [user.username for user in draft_order] return self.draft_order @property def draft_index(self): - return self.cache.get(self.keys.draft_index,0) + return self.cache.get(self.cache_keys.draft_index,0) @draft_index.setter def draft_index(self, draft_index: int): - self.cache.set(self.keys.draft_index, int(draft_index)) + self.cache.set(self.cache_keys.draft_index, int(draft_index)) def draft_index_advance(self, n: int = 1): self.draft_index += n @@ -171,49 +178,67 @@ class DraftStateManager: # === Current Nomination / Bid === def start_nomination(self, movie_id: int): - self.cache.set(self.keys.current_movie, movie_id) - self.cache.delete(self.keys.bids) + self.cache.set(self.cache_keys.current_movie, movie_id) + self.cache.delete(self.cache_keys.bids) def place_bid(self, user_id: int, amount: int): bids = self.get_bids() bids[user_id] = amount - self.cache.set(self.keys.bids, json.dumps(bids)) + self.cache.set(self.cache_keys.bids, json.dumps(bids)) def get_bids(self) -> dict: - return json.loads(self.cache.get(self.keys.bids) or "{}") + return json.loads(self.cache.get(self.cache_keys.bids) or "{}") def current_movie(self) -> Movie | None: - movie_id = self.cache.get(self.keys.current_movie) + movie_id = self.cache.get(self.cache_keys.current_movie) return Movie.objects.filter(pk=movie_id).first() if movie_id else None - def start_timer(self): + def start_bidding(self): + seconds = self.settings.bidding_duration start_time = time.time() end_time = start_time + seconds - self.cache.set(self.keys.bid_timer_end, end_time) - self.cache.set(self.keys.bid_timer_start, start_time) + self.cache.set(self.cache_keys.bid_timer_end, end_time) + self.cache.set(self.cache_keys.bid_timer_start, start_time) def get_timer_end(self) -> str | None: - return self.cache.get(self.keys.bid_timer_end) + return self.cache.get(self.cache_keys.bid_timer_end) def get_timer_start(self) -> str | None: - return self.cache.get(self.keys.bid_timer_start) + return self.cache.get(self.cache_keys.bid_timer_start) # === Sync Snapshot === - def get_summary(self) -> dict: + def to_dict(self) -> dict: picks = self.next_picks(include_current=True) return { "phase": self.phase, "draft_order": self.draft_order, "draft_index": self.draft_index, "connected_participants": self.connected_participants, - "current_movie": self.cache.get(self.keys.current_movie), + "current_movie": self.cache.get(self.cache_keys.current_movie), # "bids": self.get_bids(), "bidding_timer_end": self.get_timer_end(), "bidding_timer_start": self.get_timer_start(), "current_pick": picks[0] if picks else None, "next_picks": picks[1:] if picks else [] } + + # def __dict__(self): + # return self.get_summary() + + def keys(self): + # return an iterable of keys + return self.to_dict().keys() + + def __getitem__(self, key): + return self.to_dict()[key] + + def __iter__(self): + # used for `dict(self.draft_state)` and iteration + return iter(self.to_dict()) + + def __len__(self): + return len(self.to_dict()) OrderType = Literal["snake", "linear"] def _round_and_pick(overall: int, n: int) -> Tuple[int, int]: diff --git a/draft/templates/draft/room_admin.dj.html b/draft/templates/draft/room_admin.dj.html index 8f0bc47..fbd6733 100644 --- a/draft/templates/draft/room_admin.dj.html +++ b/draft/templates/draft/room_admin.dj.html @@ -1,10 +1,8 @@ {% extends "base.dj.html" %} -{% block content %} -

Draft Room: {{ league.name }} – {{ season.label }} {{ season.year }}

+{% block body %} {% load static %} -
- -{% endblock %} \ No newline at end of file +
+{% endblock body %} \ No newline at end of file diff --git a/frontend/src/apps/draft/admin/DraftAdmin.jsx b/frontend/src/apps/draft/admin/DraftAdmin.jsx index 7e76b6c..af4e283 100644 --- a/frontend/src/apps/draft/admin/DraftAdmin.jsx +++ b/frontend/src/apps/draft/admin/DraftAdmin.jsx @@ -7,6 +7,7 @@ import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from '. import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "../common/utils.js" import { DraftMoviePool } from "../common/DraftMoviePool.jsx" import { DraftCountdownClock } from "../common/DraftCountdownClock.jsx" +import { DraftParticipant } from "../participant/DraftParticipant.jsx"; import { jsxs } from "react/jsx-runtime"; @@ -14,7 +15,6 @@ import { jsxs } from "react/jsx-runtime"; const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) => { return (
-
    @@ -46,19 +46,19 @@ export const DraftAdmin = ({ draftSessionId }) => { }) }, []) - useEffect(()=>{ + useEffect(() => { if (!socket) return; - const openHandler = (event)=>{ + const openHandler = (event) => { console.log('Websocket Opened') } - const closeHandler = (event)=>{ + const closeHandler = (event) => { console.log('Websocket Closed') } - socket.addEventListener('open', openHandler ); - socket.addEventListener('close', closeHandler ); - return ()=>{ - socket.removeEventListener('open', openHandler ); - socket.removeEventListener('close', closeHandler ); + socket.addEventListener('open', openHandler); + socket.addEventListener('close', closeHandler); + return () => { + socket.removeEventListener('open', openHandler); + socket.removeEventListener('close', closeHandler); } }, [socket]) @@ -67,7 +67,7 @@ export const DraftAdmin = ({ draftSessionId }) => { const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState) const userIdentifyMessageHandler = (event) => handleUserIdentifyMessages(event, setCurrentUser) - const handleNominationRequest = (event)=> { + const handleNominationRequest = (event) => { const message = JSON.parse(event.data) const { type, payload } = message; if (type == DraftMessage.NOMINATION_SUBMIT_REQUEST) { @@ -79,15 +79,15 @@ export const DraftAdmin = ({ draftSessionId }) => { )) } } - socket.addEventListener('message', draftStatusMessageHandler ); - socket.addEventListener('message', userIdentifyMessageHandler ); - socket.addEventListener('message', handleNominationRequest ); - - + socket.addEventListener('message', draftStatusMessageHandler); + socket.addEventListener('message', userIdentifyMessageHandler); + socket.addEventListener('message', handleNominationRequest); + + return () => { socket.removeEventListener('message', draftStatusMessageHandler) - socket.removeEventListener('message', userIdentifyMessageHandler ); - socket.removeEventListener('message', handleNominationRequest ); + socket.removeEventListener('message', userIdentifyMessageHandler); + socket.removeEventListener('message', handleNominationRequest); }; }, [socket]); @@ -134,36 +134,31 @@ export const DraftAdmin = ({ draftSessionId }) => { const handleStartBidding = () => { socket.send( JSON.stringify( - {type: DraftMessage.BID_START_REQUEST} + { type: DraftMessage.BID_START_REQUEST } ) ) } return ( -
    -
    -

    Draft Panel

    -
    - - +
    +
    + +
    - -
    - - +
    + + + +
    + +
    + { handlePhaseChange('next') }} prevPhaseHandler={() => { handlePhaseChange('previous') }}>
    - - { handlePhaseChange('next') }} prevPhaseHandler={() => { handlePhaseChange('previous') }}> - +
    ); }; \ No newline at end of file diff --git a/frontend/src/apps/draft/common/DraftCountdownClock.jsx b/frontend/src/apps/draft/common/DraftCountdownClock.jsx index 2328488..2d25a8f 100644 --- a/frontend/src/apps/draft/common/DraftCountdownClock.jsx +++ b/frontend/src/apps/draft/common/DraftCountdownClock.jsx @@ -27,7 +27,7 @@ export function DraftCountdownClock({ endTime, onFinish }) { return (
    - {minutes}:{pad(secs)} + {!isNaN(minutes) && !isNaN(secs) ? `${minutes}:${pad(secs)}` : "0:00"}
    ); diff --git a/frontend/src/apps/draft/participant/DraftParticipant.jsx b/frontend/src/apps/draft/participant/DraftParticipant.jsx index 0bd6587..8067f65 100644 --- a/frontend/src/apps/draft/participant/DraftParticipant.jsx +++ b/frontend/src/apps/draft/participant/DraftParticipant.jsx @@ -85,7 +85,7 @@ export const DraftParticipant = ({ draftSessionId }) => { }, [socket]); return ( -
    +

    Draft Live

    diff --git a/frontend/src/scss/styles.scss b/frontend/src/scss/styles.scss index 398b7f1..26e4532 100644 --- a/frontend/src/scss/styles.scss +++ b/frontend/src/scss/styles.scss @@ -124,30 +124,34 @@ } } -.draft-participant { - display: flex; - flex-wrap: wrap; /* allow panels to wrap */ - gap: 1rem; /* space between panels */ - justify-content: center; /* center the panels horizontally */ +#draft-participant-root, +#draft-admin-root { + @extend .flex-grow-1; + .wrapper:first-child { + display: flex; + flex-wrap: wrap; /* allow panels to wrap */ + gap: 1rem; /* space between panels */ + justify-content: center; /* center the panels horizontally */ - .panel { - flex: 1 1 350px; /* grow/shrink, base width */ - max-width: 450px; /* never go beyond this */ - min-width: 300px; /* keeps them from getting too small */ - } - .panel.draft-live { - .draft-live-state-container { - @extend .d-flex; - .countdown-clock { - @extend .fs-1; - @extend .fw-bold; - @extend .col; - @extend .align-content-center; - @extend .text-center; - } - .pick-description{ - @extend .col; + .panel { + flex: 1 1 350px; /* grow/shrink, base width */ + max-width: 450px; /* never go beyond this */ + min-width: 300px; /* keeps them from getting too small */ + } + .panel.draft-live { + .draft-live-state-container { + @extend .d-flex; + .countdown-clock { + @extend .fs-1; + @extend .fw-bold; + @extend .col; + @extend .align-content-center; + @extend .text-center; + } + .pick-description { + @extend .col; + } } } } -} \ No newline at end of file +}