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
This commit is contained in:
2025-08-12 21:34:02 -05:00
parent cd4d974fce
commit 71f0f01abc
13 changed files with 246 additions and 322 deletions

View File

@@ -2,16 +2,17 @@
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}My Site{% endblock %}</title>
<link
rel="stylesheet"
href="https://cdn.datatables.net/2.3.2/css/dataTables.bootstrap5.css"
/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
<title>
{% block title %}My Site{% endblock %}
</title>
<link rel="stylesheet"
href="https://cdn.datatables.net/2.3.2/css/dataTables.bootstrap5.css" />
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
{% if DEBUG %}
<script defer src="http://localhost:3000/dist/bundle.js"></script>
<script defer src="http://localhost:3000/dist/bundle.js"></script>
{% else %}
<script defer src="{% static 'bundle.js' %}"></script>
<script defer src="{% static 'bundle.js' %}"></script>
{% endif %}
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script>
@@ -27,39 +28,40 @@
</a>
</div>
<div>
{%block navbar%}
{%endblock%}
{% block navbar %}{% endblock %}
</div>
{% if user.is_authenticated %}
<div>
<div class="border border-secondary rounded p-1">{{ user.username }}</div>
</div>
<div>
<div class="border border-secondary rounded p-1">{{ user.username }}</div>
</div>
{% else %}
<div>
<div class="btn btn-outline-secondary">Login</div>
</div>
<div>
<div class="btn btn-outline-secondary">Login</div>
</div>
{% endif %}
</nav>
<main class="container mt-4">
{% block breadcrumbs%}
<nav aria-label="breadcrumb">
{%if breadcrumbs%}
<ol class="breadcrumb">
{% for crumb in breadcrumbs %}
<li class="breadcrumb-item {% if forloop.last %}active{% endif %}" aria-current="page">{% if not forloop.last %}<a href="{{crumb.url}}">{{crumb.label}}</a>{%else%}{{crumb.label}}{%endif%}</li>
{%endfor%}
</ol>
{%endif%}
</nav>
{% endblock%} {% block content %}
<!-- Default content -->
{% endblock %}
</main>
<footer class="text-muted text-center mt-5">
<small>&copy; Sack Lunch</small>
</footer>
</body>
</html>
{% block body %}
<main class="container mt-4">
{% block breadcrumbs %}
<nav aria-label="breadcrumb">
{% if breadcrumbs %}
<ol class="breadcrumb">
{% for crumb in breadcrumbs %}
<li class="breadcrumb-item {% if forloop.last %}active{% endif %}"
aria-current="page">
{% if not forloop.last %}
<a href="{{ crumb.url }}">{{ crumb.label }}</a>{% else %}{{ crumb.label }}{% endif %}
</li>
{% endfor %}
</ol>
{% endif %}
</nav>
{% endblock breadcrumbs %}
{% block content %}{% endblock content %}
{% endblock body %}
</main>
<footer class="text-muted text-center mt-5">
<small>&copy; Sack Lunch</small>
</footer>
</body>
</html>

View File

@@ -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:]
},
},
)

View File

@@ -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(),
}
"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

View File

@@ -1,14 +1,8 @@
{% extends "base.dj.html" %}
{% block content %}
<h1>Draft Room: {{ league.name }} {{ season.label }} {{ season.year }}</h1>
{% block body %}
{% load static %}
<script>
window.draftSessionId = "{{ draft_id_hashed }}"
</script>
<div id="draft-participant-root" data-draft-id="{{ draft_id_hashed }}"></div>
{% if DEBUG %}
<script src="http://localhost:3000/dist/bundle.js"></script>
{% else %}
<script src="{% static 'bundle.js' %}"></script>
{% endif %}
{% endblock content %}
{% endblock body %}

View File

@@ -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"

View File

@@ -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';

View File

@@ -25,8 +25,10 @@ export function DraftCountdownClock({ endTime, onFinish }) {
const pad = n => String(n).padStart(2, "0");
return (
<span>
{minutes}:{pad(secs)}
</span>
<div className="countdown-clock">
<span>
{minutes}:{pad(secs)}
</span>
</div>
);
}

View File

@@ -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);

View File

@@ -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 } : {}),
}));
};

View File

@@ -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 (
<WebSocketContext.Provider value={socketRef.current}>
{children}
</WebSocketContext.Provider>
);
};
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 (
<div className="d-flex align-items-center gap-2">
<span
className="status-dot"
style={{
width: "10px",
height: "10px",
borderRadius: "50%",
backgroundColor: isConnected ? "green" : "red",
display: "inline-block",
}}
></span>
<span>{isConnected ? "Connected" : "Disconnected"}</span>
</div>
);
};
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 (
<div className="message-logger mt-4">
<label>📥 Received Messages</label>
<div style={{ maxHeight: '300px', overflowY: 'scroll', fontFamily: 'monospace', background: '#f8f9fa', padding: '1em', border: '1px solid #ccc' }}>
{messages.map((msg, i) => (
<div key={i}>
<pre style={{ margin: 0 }}>{JSON.stringify(msg, null, 2)}</pre>
<hr />
</div>
))}
<div ref={bottomRef} />
</div>
</div>
);
};
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 (
<WebSocketContext url={wsUrl}>
<div className="container draft-panel">
<h3>Draft Admin Panel</h3>
<WebSocketStatus socket={socket} />
{/* <MessageLogger socket={socketRef.current} /> */}
<label>Connected Particpants</label>
<input
type="text"
readOnly disabled
value={connectedParticipants ? JSON.stringify(connectedParticipants) : ""}
/>
<label>Draft Phase</label>
<input
type="text"
readOnly disabled
value={draftPhase ? JSON.stringify(draftPhase) : ""}
/>
<button onClick={() => handlePhaseChange(DraftPhase.DETERMINE_ORDER)} className="btn btn-primary mt-2 me-2">
Determine Draft Order
</button>
<button onClick={handleRequestDraftSummary} className="btn btn-primary mt-2">
Request status
</button>
</div>
</WebSocketContext>
);
};
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 (
<div className="container draft-panel">
<h3 >Draft Participant Panel</h3>
<WebSocketStatus socket={socketRef.current} />
<label>Latest Message</label>
<MessageLogger socket={socketRef.current}></MessageLogger>
</div>
);
};

View File

@@ -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}) => {
<label>Nominate</label>
<div className="d-flex">
<form onSubmit={requestNomination}>
<select className="form-control" name="movie">
{movies.map(m=>(
<option key={m.id} value={m.id}>{m.title}</option>
))}
</select>
<button className="btn btn-primary">Nominate</button>
<select className="form-control" name="movie">
{movies.map(m => (
<option key={m.id} value={m.id}>{m.title}</option>
))}
</select>
<button className="btn btn-primary">Nominate</button>
</form>
</div>
</div>
@@ -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 (
<div className="container draft-panel">
<div className="d-flex justify-content-between border-bottom mb-2 p-1">
<h3>Draft Panel</h3>
<WebSocketStatus socket={socket} />
</div>
<ParticipantList
currentUser={currentUser}
draftState={draftState}
draftDetails={draftDetails}
/>
<DraftMoviePool isParticipant={true} draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
<div className="draft-participant">
<section class="panel draft-live">
<header class="panel-header d-flex justify-content-between align-items-center">
<h2 class="panel-title">Draft Live</h2>
<div class="d-flex gap-1">
<div class="phase-indicator badge bg-primary">{DraftPhaseLabel[draftState.phase]}</div>
<WebSocketStatus socket={socket} />
</div>
</header>
<div class="panel-body">
<div class="draft-live-state-container">
<DraftCountdownClock endTime={draftState.bidding_timer_end}></DraftCountdownClock>
<div class="pick-description">
{console.log("draft_state", draftState)}
<div>Round {draftState.current_pick?.round}</div>
<div>Pick {draftState.current_pick?.pick_in_round}</div>
<div>{draftState.current_pick?.overall+1} Overall</div>
</div>
</div>
<div class="current-movie card"></div>
<div class="bid-controls btn-group"></div>
<ParticipantList
currentUser={draftState.current_pick?.participant}
draftState={draftState}
draftDetails={draftDetails}
/>
</div>
</section>
<section class="panel draft-board">
<header class="panel-header">
<h2 class="panel-title">Draft Board</h2>
</header>
<div class="panel-body">
<div class="current-movie-detail card"></div>
<div class="movie-filters"></div>
<DraftMoviePool isParticipant={true} draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
</div>
</section>
<section class="panel my-team">
<header class="panel-header">
<h2 class="panel-title">My Team</h2>
</header>
<div class="panel-body">
<ul class="team-movie-list list-group">
<li class="team-movie-item list-group-item"></li>
</ul>
<div class="budget-status"></div>
</div>
</section>
<section class="panel teams">
<header class="panel-header">
<h2 class="panel-title">Teams</h2>
</header>
<div class="panel-body">
<ul class="team-list list-group">
<li class="team-item list-group-item">
<div class="team-name fw-bold"></div>
<ul class="team-movie-list list-group list-group-flush">
<li class="team-movie-item list-group-item"></li>
</ul>
</li>
</ul>
</div>
</section>
<NominateMenu socket={socket} currentUser={currentUser} draftState={draftState} draftDetails={draftDetails}></NominateMenu>
<DraftCountdownClock endTime={draftState.bidding_timer_end}></DraftCountdownClock>
</div>
);
};

View File

@@ -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'

View File

@@ -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;
}
}
}
}