Add timed bidding support with countdown displays and debug view

- Added `bidding_duration` field to `DraftSessionSettings` model and migration.
- Updated `DraftStateManager` to manage bidding start/end times using session settings.
- Extended WebSocket payloads to include bidding timer data.
- Added `DraftCountdownClock` React component and integrated into admin and participant UIs.
- Created new `DraftDebug` view, template, and front-end component for real-time state debugging.
- Updated utility functions to handle new timer fields in draft state.
- Changed script tags in templates to load with `defer` for non-blocking execution.
This commit is contained in:
2025-08-10 18:19:54 -05:00
parent b08a345563
commit cd4d974fce
16 changed files with 245 additions and 85 deletions

View File

@@ -9,9 +9,9 @@
/> />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
{% if DEBUG %} {% if DEBUG %}
<script src="http://localhost:3000/dist/bundle.js"></script> <script defer src="http://localhost:3000/dist/bundle.js"></script>
{% else %} {% else %}
<script src="{% static 'bundle.js' %}"></script> <script defer src="{% static 'bundle.js' %}"></script>
{% endif %} {% endif %}
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script> <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> <script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script>

View File

@@ -40,7 +40,7 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
self.group_names = DraftGroupChannelNames(draft_hashid) self.group_names = DraftGroupChannelNames(draft_hashid)
self.cache_keys = DraftCacheKeys(draft_hashid) self.cache_keys = DraftCacheKeys(draft_hashid)
self.draft_state = DraftStateManager(draft_hashid) self.draft_state = DraftStateManager(draft_hashid, self.draft_session.settings)
self.user = self.scope["user"] self.user = self.scope["user"]
if not self.should_accept_user(): if not self.should_accept_user():
@@ -133,7 +133,7 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
draft_session_id = DraftSession.decode_id(draft_session_id_hashed) draft_session_id = DraftSession.decode_id(draft_session_id_hashed)
if draft_session_id: if draft_session_id:
draft_session = DraftSession.objects.select_related( draft_session = DraftSession.objects.select_related(
"season", "season__league" "season", "season__league", "settings"
).get(pk=draft_session_id) ).get(pk=draft_session_id)
else: else:
raise Exception() raise Exception()
@@ -198,14 +198,17 @@ class DraftAdminConsumer(DraftConsumerBase):
} }
) )
if event_type == DraftMessage.BID_START_REQUEST: if event_type == DraftMessage.BID_START_REQUEST:
self.draft_state self.draft_state.start_timer()
await self.channel_layer.group_send( await self.channel_layer.group_send(
self.group_names.session, self.group_names.session,
{ {
"type": "broadcast.session", "type": "broadcast.session",
"subtype": DraftMessage.BID_START_INFORM, "subtype": DraftMessage.BID_START_INFORM,
"payload": { "payload": {
"current_movie": self.draft_state.get_summary()['current_movie'] "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()
} }
} }
) )

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.2.4 on 2025-08-10 22:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boxofficefantasy', '0009_alter_moviemetric_value_alter_pick_bid_amount_and_more'),
('draft', '0006_remove_draftparticipant_draft_and_more'),
]
operations = [
migrations.AddField(
model_name='draftsessionsettings',
name='bidding_duration',
field=models.IntegerField(default=90),
),
migrations.AlterField(
model_name='draftpick',
name='draft',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='draft_picks', to='draft.draftsession'),
),
migrations.AlterField(
model_name='draftsession',
name='movies',
field=models.ManyToManyField(blank=True, related_name='draft_sessions', to='boxofficefantasy.movie'),
),
migrations.AlterField(
model_name='draftsessionparticipant',
name='draft_session',
field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, to='draft.draftsession'),
),
]

View File

@@ -62,6 +62,7 @@ class DraftSessionSettings(Model):
draft_session = OneToOneField( draft_session = OneToOneField(
DraftSession, on_delete=CASCADE, related_name="settings" DraftSession, on_delete=CASCADE, related_name="settings"
) )
bidding_duration = IntegerField(default=90)
def __str__(self): def __str__(self):
return f"Settings for {self.draft_session}" return f"Settings for {self.draft_session}"

View File

@@ -4,6 +4,8 @@ from datetime import datetime, timedelta
from boxofficefantasy.models import Movie from boxofficefantasy.models import Movie
from django.contrib.auth.models import User from django.contrib.auth.models import User
from draft.constants import DraftPhase from draft.constants import DraftPhase
from draft.models import DraftSessionSettings
import time
class DraftCacheKeys: class DraftCacheKeys:
def __init__(self, id): def __init__(self, id):
@@ -57,9 +59,12 @@ class DraftCacheKeys:
# def participants(self): # def participants(self):
# return f"{self.prefix}:participants" # return f"{self.prefix}:participants"
# @property @property
# def bid_timer_end(self): def bid_timer_end(self):
# return f"{self.prefix}:bid_timer_end" return f"{self.prefix}:bid_timer_end"
@property
def bid_timer_start(self):
return f"{self.prefix}:bid_timer_start"
# def user_status(self, user_id): # def user_status(self, user_id):
# return f"{self.prefix}:user:{user_id}:status" # return f"{self.prefix}:user:{user_id}:status"
@@ -68,12 +73,12 @@ class DraftCacheKeys:
# return f"{self.prefix}:user:{user_id}:channel" # return f"{self.prefix}:user:{user_id}:channel"
class DraftStateManager: class DraftStateManager:
def __init__(self, session_id: int): def __init__(self, session_id: int, settings: DraftSessionSettings):
self.session_id = session_id self.session_id = session_id
self.cache = cache self.cache = cache
self.keys = DraftCacheKeys(session_id) self.keys = DraftCacheKeys(session_id)
self._initial_phase = self.cache.get(self.keys.phase, DraftPhase.WAITING.value) self._initial_phase = self.cache.get(self.keys.phase, DraftPhase.WAITING.value)
self.settings = settings
# === Phase Management === # === Phase Management ===
@property @property
@@ -135,13 +140,18 @@ class DraftStateManager:
movie_id = self.cache.get(self.keys.current_movie) movie_id = self.cache.get(self.keys.current_movie)
return Movie.objects.filter(pk=movie_id).first() if movie_id else None return Movie.objects.filter(pk=movie_id).first() if movie_id else None
def start_timer(self, seconds: int): def start_timer(self):
end_time = (datetime.now() + timedelta(seconds=seconds)).isoformat() seconds = self.settings.bidding_duration
self.cache.set(self.keys.timer_end, end_time) start_time = time.time()
self.cache.set(self.keys.timer_end, end_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)
def get_timer_end(self) -> str | None: def get_timer_end(self) -> str | None:
return self.cache.get(self.keys.timer_end).decode("utf-8") if self.cache.get(self.keys.timer_end) else None return self.cache.get(self.keys.bid_timer_end)
def get_timer_start(self) -> str | None:
return self.cache.get(self.keys.bid_timer_start)
# === Sync Snapshot === # === Sync Snapshot ===
def get_summary(self) -> dict: def get_summary(self) -> dict:
@@ -152,5 +162,6 @@ class DraftStateManager:
"connected_participants": self.connected_participants, "connected_participants": self.connected_participants,
"current_movie": self.cache.get(self.keys.current_movie), "current_movie": self.cache.get(self.keys.current_movie),
# "bids": self.get_bids(), # "bids": self.get_bids(),
# "timer_end": self.get_timer_end(), "bidding_timer_end": self.get_timer_end(),
"bidding_timer_start": self.get_timer_start(),
} }

View File

@@ -6,9 +6,5 @@
window.draftSessionId = "{{ draft_id_hashed }}" window.draftSessionId = "{{ draft_id_hashed }}"
</script> </script>
<div id="draft-admin-root" data-draft-hid="{{ draft_id_hashed }}"></div> <div id="draft-admin-root" data-draft-hid="{{ draft_id_hashed }}"></div>
{% if DEBUG %}
<script src="http://localhost:3000/dist/bundle.js"></script>
{% else %}
<script src="{% static 'bundle.js' %}"></script>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,14 @@
{% load static %}
<head>
{% if DEBUG %}
<script defer src="http://localhost:3000/dist/bundle.js"></script>
{% else %}
<script src="{% static 'bundle.js' %}"></script>
{% endif %}
</head>
<body>
<script>
window.draftSessionId = "{{ draft_id_hashed }}"
</script>
<div id="draft-debug-root" data-draft-hid="{{ draft_id_hashed }}"></div>
</body>

View File

@@ -6,6 +6,6 @@ app_name = "draft"
urlpatterns = [ urlpatterns = [
# path("", views.draft_room, name="room"), # path("", views.draft_room, name="room"),
path("session/<str:draft_session_id_hashed>/", views.draft_room, name="session"), path("session/<str:draft_session_id_hashed>/", views.draft_room, name="session"),
path("session/<str:draft_session_id_hashed>/<str:is_admin>", views.draft_room, name="admin_session"), path("session/<str:draft_session_id_hashed>/<str:subpage>", views.draft_room, name="admin_session"),
# path("<slug:league_slug>/<slug:season_slug>/", views.draft_room_list, name="room"), # path("<slug:league_slug>/<slug:season_slug>/", views.draft_room_list, name="room"),
] ]

View File

@@ -6,7 +6,7 @@ from django.contrib.auth.decorators import login_required
from boxofficefantasy_project.utils import decode_id from boxofficefantasy_project.utils import decode_id
@login_required(login_url='/login/') @login_required(login_url='/login/')
def draft_room(request, league_slug=None, season_slug=None, draft_session_id_hashed=None, is_admin=""): def draft_room(request, league_slug=None, season_slug=None, draft_session_id_hashed=None, subpage=""):
if draft_session_id_hashed: if draft_session_id_hashed:
draft_session_id = decode_id(draft_session_id_hashed) draft_session_id = decode_id(draft_session_id_hashed)
draft_session = get_object_or_404(DraftSession, id=draft_session_id) draft_session = get_object_or_404(DraftSession, id=draft_session_id)
@@ -25,8 +25,9 @@ def draft_room(request, league_slug=None, season_slug=None, draft_session_id_has
"season": season, "season": season,
} }
if is_admin == "admin": if subpage == "admin":
return render(request, "draft/room_admin.dj.html", context) return render(request, "draft/room_admin.dj.html", context)
elif subpage == "debug":
return render(request, "draft/room_debug.dj.html", context)
else: else:
return render(request, "draft/room.dj.html", context) return render(request, "draft/room.dj.html", context)

View File

@@ -0,0 +1,45 @@
import React, { useEffect, useState } from "react";;
import { useWebSocket } from "./WebSocketContext.jsx";
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from './constants.js';
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "./common/utils.js"
export const DraftDebug = ({ draftSessionId }) => {
const [draftState, setDraftState] = useState({})
const socket = useWebSocket();
if (!socket) return;
useEffect(() => {
if (!socket) return;
const openHandler = (event) => {
console.log('Websocket Opened')
}
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])
useEffect(() => {
if (!socket) return;
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
socket.addEventListener('message', draftStatusMessageHandler);
return () => {
socket.removeEventListener('message', draftStatusMessageHandler)
};
}, [socket]);
const data = { 'message': 'test' }
return (<pre style={{margin: "1em"}}>
{JSON.stringify(draftState, null, 2)}
</pre>
)
}

View File

@@ -6,6 +6,7 @@ import { ParticipantList } from "../common/ParticipantList.jsx";
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from '../constants.js'; import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from '../constants.js';
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "../common/utils.js" import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "../common/utils.js"
import { DraftMoviePool } from "../common/DraftMoviePool.jsx" import { DraftMoviePool } from "../common/DraftMoviePool.jsx"
import { DraftCountdownClock } from "../common/DraftCountdownClock.jsx"
import { jsxs } from "react/jsx-runtime"; import { jsxs } from "react/jsx-runtime";
@@ -69,7 +70,6 @@ export const DraftAdmin = ({ draftSessionId }) => {
const handleNominationRequest = (event)=> { const handleNominationRequest = (event)=> {
const message = JSON.parse(event.data) const message = JSON.parse(event.data)
const { type, payload } = message; const { type, payload } = message;
console.log('passing through nomination request', message)
if (type == DraftMessage.NOMINATION_SUBMIT_REQUEST) { if (type == DraftMessage.NOMINATION_SUBMIT_REQUEST) {
socket.send(JSON.stringify( socket.send(JSON.stringify(
{ {
@@ -87,7 +87,7 @@ export const DraftAdmin = ({ draftSessionId }) => {
return () => { return () => {
socket.removeEventListener('message', draftStatusMessageHandler) socket.removeEventListener('message', draftStatusMessageHandler)
socket.removeEventListener('message', userIdentifyMessageHandler ); socket.removeEventListener('message', userIdentifyMessageHandler );
socket.remove('message', handleNominationRequest ); socket.removeEventListener('message', handleNominationRequest );
}; };
}, [socket]); }, [socket]);
@@ -162,8 +162,8 @@ export const DraftAdmin = ({ draftSessionId }) => {
<button onClick={handleStartBidding} className="btn btn-primary">Start Bidding</button> <button onClick={handleStartBidding} className="btn btn-primary">Start Bidding</button>
</div> </div>
<DraftMoviePool draftDetails={draftDetails} draftState={draftState}></DraftMoviePool> <DraftMoviePool draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
<DraftPhaseDisplay draftPhase={draftState.phase} nextPhaseHandler={() => { handlePhaseChange('next') }} prevPhaseHandler={() => { handlePhaseChange('previous') }}></DraftPhaseDisplay> <DraftPhaseDisplay draftPhase={draftState.phase} nextPhaseHandler={() => { handlePhaseChange('next') }} prevPhaseHandler={() => { handlePhaseChange('previous') }}></DraftPhaseDisplay>
<DraftCountdownClock endTime={draftState.bidding_timer_end}></DraftCountdownClock>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,32 @@
import React, { useEffect, useState } from "react";
export function DraftCountdownClock({ endTime, onFinish }) {
// endTime is in seconds (Unix time)
const getTimeLeft = (et) => Math.max(0, Math.floor(et - Date.now() / 1000));
const [timeLeft, setTimeLeft] = useState(getTimeLeft(endTime));
useEffect(() => {
if (timeLeft <= 0) {
if (onFinish) onFinish();
return;
}
const timer = setInterval(() => {
const t = getTimeLeft(endTime);
setTimeLeft(t);
if (t <= 0 && onFinish) onFinish();
}, 100);
return () => clearInterval(timer);
// eslint-disable-next-line
}, [endTime, onFinish, timeLeft]);
const minutes = Math.floor(timeLeft / 60);
const secs = timeLeft % 60;
const pad = n => String(n).padStart(2, "0");
return (
<span>
{minutes}:{pad(secs)}
</span>
);
}

View File

@@ -8,6 +8,7 @@ export const DraftMoviePool = ({ isParticipant, draftDetails, draftState }) => {
return ( return (
<div className="movie-pool-container"> <div className="movie-pool-container">
<label>Movies</label>
<ul> <ul>
{movies.map(m => ( {movies.map(m => (
<li key={m.id} className={`${current_movie == m.id ? "current-movie fw-bold" : null }`}> <li key={m.id} className={`${current_movie == m.id ? "current-movie fw-bold" : null }`}>

View File

@@ -1,69 +1,78 @@
import { DraftMessage } from "../constants" import { DraftMessage } from "../constants";
export async function fetchDraftDetails(draftSessionId) { export async function fetchDraftDetails(draftSessionId) {
return fetch(`/api/draft/${draftSessionId}/`) return fetch(`/api/draft/${draftSessionId}/`)
.then((response) => { .then((response) => {
if (response.ok) { if (response.ok) {
return response.json() return response.json();
} } else {
else { throw new Error();
throw new Error() }
} })
}) .catch((err) => {
.catch((err) => { console.error("Error fetching draft details", err);
console.error("Error fetching draft details", err) });
}) }
}
export async function fetchMovieDetails(draftSessionId) { export async function fetchMovieDetails(draftSessionId) {
return fetch(`/api/draft/${draftSessionId}/movie/`) return fetch(`/api/draft/${draftSessionId}/movie/`)
.then((response) => { .then((response) => {
if (response.ok) { if (response.ok) {
return response.json() return response.json();
} } else {
else { throw new Error();
throw new Error() }
} })
}) .catch((err) => {
.catch((err) => { console.error("Error fetching draft details", err);
console.error("Error fetching draft details", err) });
}) }
}
export function isEmptyObject(obj) { export function isEmptyObject(obj) {
return obj == null || (Object.keys(obj).length === 0 && obj.constructor === Object); return (
obj == null || (Object.keys(obj).length === 0 && obj.constructor === Object)
);
} }
export const handleDraftStatusMessages = (event, setDraftState) => { export const handleDraftStatusMessages = (event, setDraftState) => {
const message = JSON.parse(event.data) const message = JSON.parse(event.data);
const { type, payload } = message; const { type, payload } = message;
console.log("Message: ", type, event?.data) console.log("Message: ", type, event?.data);
if (!payload) return if (!payload) return;
const {connected_participants, phase, draft_order, draft_index, current_movie} = payload const {
connected_participants,
phase,
draft_order,
draft_index,
current_movie,
bidding_timer_end,
bidding_timer_start
} = payload;
if (type == DraftMessage.STATUS_SYNC_INFORM) { if (type == DraftMessage.STATUS_SYNC_INFORM) {
setDraftState(payload) setDraftState(payload);
} }
setDraftState(prev=>({ setDraftState((prev) => ({
...prev, ...prev,
...(connected_participants ? { connected_participants } : {}), ...(connected_participants ? { connected_participants } : {}),
...(draft_order ? { draft_order } : {}), ...(draft_order ? { draft_order } : {}),
...(draft_index ? { draft_index } : {}), ...(draft_index ? { draft_index } : {}),
...(phase ? { phase: Number(phase) } : {}), ...(phase ? { phase: Number(phase) } : {}),
...(current_movie ? {current_movie} : {}), ...(current_movie ? { current_movie } : {}),
})) ...(bidding_timer_end ? { bidding_timer_end: Number(bidding_timer_end) } : {}),
...(bidding_timer_start ? { bidding_timer_start: Number(bidding_timer_start) } : {}),
} }));
};
export const handleUserIdentifyMessages = (event, setUser) => { export const handleUserIdentifyMessages = (event, setUser) => {
const message = JSON.parse(event.data) const message = JSON.parse(event.data);
const { type, payload } = message; const { type, payload } = message;
if (type==DraftMessage.USER_IDENTIFICATION_INFORM){ if (type == DraftMessage.USER_IDENTIFICATION_INFORM) {
console.log("Message: ", type, event.data) console.log("Message: ", type, event.data);
const {user} = payload const { user } = payload;
setUser(user) setUser(user);
} }
} };

View File

@@ -7,6 +7,7 @@ import { DraftMessage, DraftPhases } from '../constants.js';
import { fetchDraftDetails, handleUserIdentifyMessages, isEmptyObject } from "../common/utils.js"; import { fetchDraftDetails, handleUserIdentifyMessages, isEmptyObject } from "../common/utils.js";
import { DraftMoviePool } from "../common/DraftMoviePool.jsx"; import { DraftMoviePool } from "../common/DraftMoviePool.jsx";
import { ParticipantList } from "../common/ParticipantList.jsx"; import { ParticipantList } from "../common/ParticipantList.jsx";
import { DraftCountdownClock } from "../common/DraftCountdownClock.jsx"
import { handleDraftStatusMessages } from '../common/utils.js' import { handleDraftStatusMessages } from '../common/utils.js'
const NominateMenu = ({socket, draftState, draftDetails, currentUser}) => { const NominateMenu = ({socket, draftState, draftDetails, currentUser}) => {
@@ -30,7 +31,6 @@ const NominateMenu = ({socket, draftState, draftDetails, currentUser}) => {
return ( return (
<div> <div>
<label>Nominate</label> <label>Nominate</label>
{draftState.draft_order[draftState.draft_index]}
<div className="d-flex"> <div className="d-flex">
<form onSubmit={requestNomination}> <form onSubmit={requestNomination}>
<select className="form-control" name="movie"> <select className="form-control" name="movie">
@@ -98,6 +98,7 @@ export const DraftParticipant = ({ draftSessionId }) => {
<DraftMoviePool isParticipant={true} draftDetails={draftDetails} draftState={draftState}></DraftMoviePool> <DraftMoviePool isParticipant={true} draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
<NominateMenu socket={socket} currentUser={currentUser} draftState={draftState} draftDetails={draftDetails}></NominateMenu> <NominateMenu socket={socket} currentUser={currentUser} draftState={draftState} draftDetails={draftDetails}></NominateMenu>
<DraftCountdownClock endTime={draftState.bidding_timer_end}></DraftCountdownClock>
</div> </div>
); );
}; };

View File

@@ -5,10 +5,12 @@ import { createRoot } from "react-dom/client";
import { WebSocketProvider } from "./apps/draft/WebSocketContext.jsx"; import { WebSocketProvider } from "./apps/draft/WebSocketContext.jsx";
import { DraftAdmin } from "./apps/draft/admin/DraftAdmin.jsx"; import { DraftAdmin } from "./apps/draft/admin/DraftAdmin.jsx";
import { DraftParticipant} from './apps/draft/participant/DraftParticipant.jsx' import { DraftParticipant} from './apps/draft/participant/DraftParticipant.jsx'
import { DraftDebug} from './apps/draft/DraftDebug.jsx'
const draftAdminRoot = document.getElementById("draft-admin-root"); const draftAdminRoot = document.getElementById("draft-admin-root");
const draftPartipantRoot = document.getElementById("draft-participant-root") const draftPartipantRoot = document.getElementById("draft-participant-root")
const draftDebugRoot = document.getElementById("draft-debug-root")
const {draftSessionId} = window; // from backend template const {draftSessionId} = window; // from backend template
if (draftPartipantRoot) { if (draftPartipantRoot) {
@@ -27,3 +29,12 @@ if (draftAdminRoot) {
</WebSocketProvider> </WebSocketProvider>
); );
} }
if (draftDebugRoot) {
console.log('draft-debug')
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`;
createRoot(draftDebugRoot).render(
<WebSocketProvider url={wsUrl}>
<DraftDebug draftSessionId={draftSessionId}/>
</WebSocketProvider>
)
}