From 71f0f01abc722e7bffab16c38b26218dece21dc2 Mon Sep 17 00:00:00 2001 From: Anthony Correa Date: Tue, 12 Aug 2025 21:34:02 -0500 Subject: [PATCH] Improve draft UI state handling, layout, and order logic - Added current/next pick info, updated server draft logic for order/snake - Refactored WebSocketContext, removed dead code, improved CSS/layout - Cleaned up template blocks, admin, and participant panel structure --- boxofficefantasy/templates/base.dj.html | 80 +++--- draft/consumers.py | 18 +- draft/state.py | 73 +++++- draft/templates/draft/room.dj.html | 10 +- frontend/src/apps/draft/DraftDebug.jsx | 2 +- frontend/src/apps/draft/admin/DraftAdmin.jsx | 2 +- .../apps/draft/common/DraftCountdownClock.jsx | 8 +- .../draft/{ => common}/WebSocketContext.jsx | 2 +- frontend/src/apps/draft/common/utils.js | 7 +- frontend/src/apps/draft/index.jsx | 230 ------------------ .../draft/participant/DraftParticipant.jsx | 106 ++++++-- frontend/src/index.js | 2 +- frontend/src/scss/styles.scss | 28 +++ 13 files changed, 246 insertions(+), 322 deletions(-) rename frontend/src/apps/draft/{ => common}/WebSocketContext.jsx (80%) delete mode 100644 frontend/src/apps/draft/index.jsx diff --git a/boxofficefantasy/templates/base.dj.html b/boxofficefantasy/templates/base.dj.html index 5e1ed86..a8967f5 100644 --- a/boxofficefantasy/templates/base.dj.html +++ b/boxofficefantasy/templates/base.dj.html @@ -2,16 +2,17 @@ - {% block title %}My Site{% endblock %} - - + + {% block title %}My Site{% endblock %} + + + {% if DEBUG %} - + {% else %} - + {% endif %} @@ -27,39 +28,40 @@
- {%block navbar%} - {%endblock%} + {% block navbar %}{% endblock %}
{% if user.is_authenticated %} -
-
{{ user.username }}
-
+
+
{{ user.username }}
+
{% else %} -
-
Login
-
+
+
Login
+
{% endif %} - - -
- {% block breadcrumbs%} - - {% endblock%} {% block content %} - - {% endblock %} -
- - - - + {% block body %} +
+ {% block breadcrumbs %} + + {% endblock breadcrumbs %} + {% block content %}{% endblock content %} + {% endblock body %} +
+ + + diff --git a/draft/consumers.py b/draft/consumers.py index 846eb79..15b80ea 100644 --- a/draft/consumers.py +++ b/draft/consumers.py @@ -40,7 +40,7 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer): self.group_names = DraftGroupChannelNames(draft_hashid) self.cache_keys = DraftCacheKeys(draft_hashid) - self.draft_state = DraftStateManager(draft_hashid, self.draft_session.settings) + self.draft_state = DraftStateManager(self.draft_session) self.user = self.scope["user"] if not self.should_accept_user(): @@ -172,7 +172,7 @@ class DraftAdminConsumer(DraftConsumerBase): await self.start_nominate() if event_type == DraftMessage.DRAFT_INDEX_ADVANCE_REQUEST: - self.draft_state.draft_index += 1 + self.draft_state.draft_index_advance() await self.channel_layer.group_send( self.group_names.session, { @@ -229,18 +229,22 @@ class DraftAdminConsumer(DraftConsumerBase): ) async def determine_draft_order(self): - draft_order = random.sample( - self.draft_participants, len(self.draft_participants) - ) - self.draft_state.draft_order = [p.username for p in draft_order] + 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) + next_picks = self.draft_state.next_picks(include_current=True) await self.channel_layer.group_send( self.group_names.session, { "type": "broadcast.session", "subtype": DraftMessage.ORDER_DETERMINE_CONFIRM, - "payload": {"draft_order": self.draft_state.draft_order}, + "payload": { + "draft_order": draft_order, + "draft_index": self.draft_state.draft_index, + "current_pick": next_picks[0], + "next_picks": next_picks[1:] + }, }, ) diff --git a/draft/state.py b/draft/state.py index 586c8f2..cc893f9 100644 --- a/draft/state.py +++ b/draft/state.py @@ -4,8 +4,11 @@ from datetime import datetime, timedelta from boxofficefantasy.models import Movie from django.contrib.auth.models import User from draft.constants import DraftPhase -from draft.models import DraftSessionSettings +from draft.models import DraftSession import time +from dataclasses import dataclass +from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple +import random class DraftCacheKeys: def __init__(self, id): @@ -73,12 +76,12 @@ class DraftCacheKeys: # return f"{self.prefix}:user:{user_id}:channel" class DraftStateManager: - def __init__(self, session_id: int, settings: DraftSessionSettings): - self.session_id = session_id + def __init__(self, session: DraftSession): + self.session_id = session.hashid self.cache = cache - self.keys = DraftCacheKeys(session_id) + self.keys = DraftCacheKeys(self.session_id) self._initial_phase = self.cache.get(self.keys.phase, DraftPhase.WAITING.value) - self.settings = settings + self.settings = session.settings # === Phase Management === @property @@ -114,6 +117,13 @@ class DraftStateManager: if not isinstance(draft_order, list): return self.cache.set(self.keys.draft_order,json.dumps(draft_order)) + + def determine_draft_order(self, users: list[User]): + draft_order = random.sample( + users, len(users) + ) + self.draft_order = [user.username for user in draft_order] + return self.draft_order @property def draft_index(self): @@ -122,6 +132,42 @@ class DraftStateManager: @draft_index.setter def draft_index(self, draft_index: int): self.cache.set(self.keys.draft_index, int(draft_index)) + + def draft_index_advance(self, n: int = 1): + self.draft_index += n + return self.draft_index + + def next_picks( + self, + *, + from_overall: int | None = None, + count: int | None = None, + include_current: bool = False, + ) -> List[dict]: + """ + Convenience: return the next `count` picks starting after `from_overall` + (or after current draft_index if omitted). Each item: + {overall, round, pick_in_round, participant} + """ + if not self.draft_order: + return [] + n = len(self.draft_order) + count = count if count else len(self.draft_order) + start = self.draft_index if from_overall is None else int(from_overall) + start = start if include_current else start + 1 + + out: List[dict] = [] + for overall in range(start, start + count): + r, p = _round_and_pick(overall, n) + order_type = "snake" + order = _round_order(r, order_type, self.draft_order) + out.append({ + "overall": overall, + "round": r, + "pick_in_round": p, + "participant": order[p - 1], + }) + return out # === Current Nomination / Bid === def start_nomination(self, movie_id: int): @@ -155,6 +201,7 @@ class DraftStateManager: # === Sync Snapshot === def get_summary(self) -> dict: + picks = self.next_picks(include_current=True) return { "phase": self.phase, "draft_order": self.draft_order, @@ -164,4 +211,18 @@ class DraftStateManager: # "bids": self.get_bids(), "bidding_timer_end": self.get_timer_end(), "bidding_timer_start": self.get_timer_start(), - } \ No newline at end of file + "current_pick": picks[0] if picks else None, + "next_picks": picks[1:] if picks else [] + } + +OrderType = Literal["snake", "linear"] +def _round_and_pick(overall: int, n: int) -> Tuple[int, int]: + """overall -> (round_1_based, pick_in_round_1_based)""" + r = overall // n + 1 + p = overall % n + 1 + return r, p + +def _round_order(round_num: int, order_type: OrderType, r1: Sequence[Any]) -> Sequence[Any]: + if order_type == "linear" or (round_num % 2 == 1): + return r1 + return list(reversed(r1)) # even rounds in snake \ No newline at end of file diff --git a/draft/templates/draft/room.dj.html b/draft/templates/draft/room.dj.html index e1feb50..7dbb4c0 100644 --- a/draft/templates/draft/room.dj.html +++ b/draft/templates/draft/room.dj.html @@ -1,14 +1,8 @@ {% extends "base.dj.html" %} -{% block content %} -

Draft Room: {{ league.name }} โ€“ {{ season.label }} {{ season.year }}

+{% block body %} {% load static %}
-{% if DEBUG %} - -{% else %} - -{% endif %} -{% endblock content %} \ No newline at end of file +{% endblock body %} \ No newline at end of file diff --git a/frontend/src/apps/draft/DraftDebug.jsx b/frontend/src/apps/draft/DraftDebug.jsx index 32d9565..544c088 100644 --- a/frontend/src/apps/draft/DraftDebug.jsx +++ b/frontend/src/apps/draft/DraftDebug.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react";; -import { useWebSocket } from "./WebSocketContext.jsx"; +import { useWebSocket } from "./common/WebSocketContext.jsx"; import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from './constants.js'; import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "./common/utils.js" diff --git a/frontend/src/apps/draft/admin/DraftAdmin.jsx b/frontend/src/apps/draft/admin/DraftAdmin.jsx index 5053757..7e76b6c 100644 --- a/frontend/src/apps/draft/admin/DraftAdmin.jsx +++ b/frontend/src/apps/draft/admin/DraftAdmin.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; -import { useWebSocket } from "../WebSocketContext.jsx"; +import { useWebSocket } from "../common/WebSocketContext.jsx"; import { WebSocketStatus } from "../common/WebSocketStatus.jsx"; import { ParticipantList } from "../common/ParticipantList.jsx"; import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from '../constants.js'; diff --git a/frontend/src/apps/draft/common/DraftCountdownClock.jsx b/frontend/src/apps/draft/common/DraftCountdownClock.jsx index edf9963..2328488 100644 --- a/frontend/src/apps/draft/common/DraftCountdownClock.jsx +++ b/frontend/src/apps/draft/common/DraftCountdownClock.jsx @@ -25,8 +25,10 @@ export function DraftCountdownClock({ endTime, onFinish }) { const pad = n => String(n).padStart(2, "0"); return ( - - {minutes}:{pad(secs)} - +
+ + {minutes}:{pad(secs)} + +
); } \ No newline at end of file diff --git a/frontend/src/apps/draft/WebSocketContext.jsx b/frontend/src/apps/draft/common/WebSocketContext.jsx similarity index 80% rename from frontend/src/apps/draft/WebSocketContext.jsx rename to frontend/src/apps/draft/common/WebSocketContext.jsx index 4c50961..c507128 100644 --- a/frontend/src/apps/draft/WebSocketContext.jsx +++ b/frontend/src/apps/draft/common/WebSocketContext.jsx @@ -1,5 +1,5 @@ // WebSocketContext.jsx -import React, { useState, createContext, useContext, useRef, useEffect } from "react"; +import React, { useState, createContext, useContext } from "react"; const WebSocketContext = createContext(null); diff --git a/frontend/src/apps/draft/common/utils.js b/frontend/src/apps/draft/common/utils.js index 57161e0..13c87ac 100644 --- a/frontend/src/apps/draft/common/utils.js +++ b/frontend/src/apps/draft/common/utils.js @@ -47,7 +47,9 @@ export const handleDraftStatusMessages = (event, setDraftState) => { draft_index, current_movie, bidding_timer_end, - bidding_timer_start + bidding_timer_start, + current_pick, + next_picks } = payload; if (type == DraftMessage.STATUS_SYNC_INFORM) { @@ -62,7 +64,8 @@ export const handleDraftStatusMessages = (event, setDraftState) => { ...(phase ? { phase: Number(phase) } : {}), ...(current_movie ? { current_movie } : {}), ...(bidding_timer_end ? { bidding_timer_end: Number(bidding_timer_end) } : {}), - ...(bidding_timer_start ? { bidding_timer_start: Number(bidding_timer_start) } : {}), + ...(current_pick ? { current_pick } : {}), + ...(next_picks ? { next_picks } : {}), })); }; diff --git a/frontend/src/apps/draft/index.jsx b/frontend/src/apps/draft/index.jsx deleted file mode 100644 index 0cc51d1..0000000 --- a/frontend/src/apps/draft/index.jsx +++ /dev/null @@ -1,230 +0,0 @@ -import React, { createContext, useContext, useEffect, useState, useRef } from "react"; -import { DraftMessage, DraftPhases } from './constants.js'; - - -const WebSocketContext = createContext(null); - -export const WebSocketProvider = ({ url, children }) => { - const socketRef = useRef(null); - - useEffect(() => { - if (!socketRef.current) { - socketRef.current = new WebSocket(url); - } - - return () => { - socketRef.current?.close(); - socketRef.current = null; - }; - }, [url]); - - return ( - - {children} - - ); -}; - -export const useWebSocket = () => { - return useContext(WebSocketContext); -}; - -export const WebSocketStatus = ({ socket }) => { - const [isConnected, setIsConnected] = useState(false); - - useEffect(() => { - console.log('socket changed', socket) - if (!socket) return; - - const handleOpen = () => {console.log('socket open'); setIsConnected(true)}; - const handleClose = () => setIsConnected(false); - const handleError = () => setIsConnected(false); - - if (socket.readyState === WebSocket.OPEN) { - console.log('socket already connected') - setIsConnected(true); - } - - socket.addEventListener("open", handleOpen); - socket.addEventListener("close", handleClose); - socket.addEventListener("error", handleError); - - // ๐Ÿงน Cleanup to remove listeners when component unmounts or socket changes - return () => { - socket.removeEventListener("open", handleOpen); - socket.removeEventListener("close", handleClose); - socket.removeEventListener("error", handleError); - }; - - }, [socket]) - return ( -
- - {isConnected ? "Connected" : "Disconnected"} -
- ); -}; - -export const MessageLogger = ({ socket }) => { - const [messages, setMessages] = useState([]); - const bottomRef = useRef(null); - - useEffect(() => { - if (!socket) return; - - const handleMessage = (event) => { - const data = JSON.parse(event.data); - setMessages((prev) => [...prev, data]); - }; - - socket.addEventListener("message", handleMessage); - - return () => { - console.log('removing event listeners') - socket.removeEventListener("message", handleMessage); - }; - }, [socket]); - - useEffect(() => { - // Scroll to bottom when messages update - if (bottomRef.current) { - bottomRef.current.scrollIntoView({ behavior: "smooth" , block: 'nearest', inline: 'start'}); - } - }, [messages]); - - return ( -
- -
- {messages.map((msg, i) => ( -
-
{JSON.stringify(msg, null, 2)}
-
-
- ))} -
-
-
- ); -}; - -export const DraftAdmin = ({ draftSessionId }) => { - const [connectedParticipants, setConnectedParticipants] = useState([]); - const [draftPhase, setDraftPhase] = useState(); - - const socketRef = useWebSocket(); - const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`; - - useEffect(() => { - if (socketRef.current) return; - console.log('socket created') - socketRef.current = new WebSocket(wsUrl); - - socketRef.current.onmessage = (event) => { - const message = JSON.parse(event.data) - const { type, payload } = message; - console.log(type, event) - if (type == DraftMessage.REQUEST.JOIN_PARTICIPANT) { - console.log('join request', data) - } - else if (type == DraftMessage.CONFIRM.JOIN_PARTICIPANT) { - setConnectedParticipants(data.connected_participants) - } - else if (type == DraftMessage.CONFIRM.PHASE_CHANGE) { - console.log('phase_change') - setDraftPhase(payload.phase) - } - }; - - socketRef.current.onclose = (event) => { - console.log('Websocket Closed') - socketRef.current = null; - } - - return () => { - socketRef.current.close(); - }; - }, [wsUrl]); - - const handlePhaseChange = (destinationPhase) => { - socketRef.current.send(JSON.stringify({ type: DraftMessage.REQUEST.PHASE_CHANGE, "destination": destinationPhase })); - } - - - const handleRequestDraftSummary = () => { - socketRef.current.send(JSON.stringify({ type: 'request_summary' })) - } - - return ( - -
-

Draft Admin Panel

- - {/* */} - - - - - - -
-
- ); -}; - -export const DraftParticipant = ({ draftSessionId }) => { - const socketRef = useRef(null); - const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/participant`; - - useEffect(() => { - socketRef.current = new WebSocket(wsUrl); - - socketRef.current.onmessage = (evt) => { - const data = JSON.parse(evt.data); - console.log(data) - }; - - socketRef.current.onclose = () => { - console.warn("WebSocket connection closed."); - socketRef.current = null; - }; - - return () => { - socketRef.current.close(); - }; - }, [wsUrl]); - - const handleStartDraft = () => { - socketRef.current.send(JSON.stringify({ type: "start_draft" })); - } - - return ( -
-

Draft Participant Panel

- - - -
- ); -}; \ No newline at end of file diff --git a/frontend/src/apps/draft/participant/DraftParticipant.jsx b/frontend/src/apps/draft/participant/DraftParticipant.jsx index c35125c..0bd6587 100644 --- a/frontend/src/apps/draft/participant/DraftParticipant.jsx +++ b/frontend/src/apps/draft/participant/DraftParticipant.jsx @@ -1,20 +1,20 @@ // DraftAdmin.jsx import React, { useEffect, useState } from "react"; -import { useWebSocket } from "../WebSocketContext.jsx"; +import { useWebSocket } from "../common/WebSocketContext.jsx"; import { WebSocketStatus } from "../common/WebSocketStatus.jsx"; -import { DraftMessage, DraftPhases } from '../constants.js'; +import { DraftMessage, DraftPhaseLabel, DraftPhases } from '../constants.js'; import { fetchDraftDetails, handleUserIdentifyMessages, isEmptyObject } from "../common/utils.js"; import { DraftMoviePool } from "../common/DraftMoviePool.jsx"; import { ParticipantList } from "../common/ParticipantList.jsx"; import { DraftCountdownClock } from "../common/DraftCountdownClock.jsx" import { handleDraftStatusMessages } from '../common/utils.js' -const NominateMenu = ({socket, draftState, draftDetails, currentUser}) => { +const NominateMenu = ({ socket, draftState, draftDetails, currentUser }) => { if (!socket || isEmptyObject(draftDetails) || isEmptyObject(draftState)) return; const currentDrafter = draftState.draft_order[draftState.draft_index] if (currentUser != currentDrafter) return; - const {movies} = draftDetails + const { movies } = draftDetails const requestNomination = (event) => { event.preventDefault() @@ -33,12 +33,12 @@ const NominateMenu = ({socket, draftState, draftDetails, currentUser}) => {
- - + +
@@ -79,26 +79,86 @@ export const DraftParticipant = ({ draftSessionId }) => { socket.addEventListener('message', userIdentifyMessageHandler); return () => { - socket.removeEventListener('message', draftStatusMessageHandler) + socket.removeEventListener('message', draftStatusMessageHandler); socket.removeEventListener('message', userIdentifyMessageHandler); }; }, [socket]); return ( -
-
-

Draft Panel

- -
- - +
+
+
+

Draft Live

+
+
{DraftPhaseLabel[draftState.phase]}
+ +
+
+
+
+ +
+ {console.log("draft_state", draftState)} +
Round {draftState.current_pick?.round}
+
Pick {draftState.current_pick?.pick_in_round}
+
{draftState.current_pick?.overall+1} Overall
+
+
+
+
+ +
+
+ +
+
+

Draft Board

+
+
+
+
+ +
+
+ + +
+
+

My Team

+
+
+
    +
  • +
+
+
+
+ + +
+
+

Teams

+
+
+
    +
  • +
    +
      +
    • +
    +
  • +
+
+
+ + - +
); }; \ No newline at end of file diff --git a/frontend/src/index.js b/frontend/src/index.js index 12e8a40..bef5fbf 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -2,7 +2,7 @@ import './scss/styles.scss' import React from "react"; import { createRoot } from "react-dom/client"; -import { WebSocketProvider } from "./apps/draft/WebSocketContext.jsx"; +import { WebSocketProvider } from "./apps/draft/common/WebSocketContext.jsx"; import { DraftAdmin } from "./apps/draft/admin/DraftAdmin.jsx"; import { DraftParticipant} from './apps/draft/participant/DraftParticipant.jsx' import { DraftDebug} from './apps/draft/DraftDebug.jsx' diff --git a/frontend/src/scss/styles.scss b/frontend/src/scss/styles.scss index 4c25abf..398b7f1 100644 --- a/frontend/src/scss/styles.scss +++ b/frontend/src/scss/styles.scss @@ -123,3 +123,31 @@ } } } + +.draft-participant { + 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; + } + } + } +} \ No newline at end of file