22 Commits

Author SHA1 Message Date
1626751083 feat: exponentially decreasing edge weight
and adjustment of negative weight
2025-06-20 14:01:04 +02:00
710b0770cc chore: remove old Analysis page 2025-05-26 07:53:58 +02:00
56c1ba11fc chore: remove unused 2025-05-26 07:51:44 +02:00
ad2b2993df fix: try to make sure order when mixed changes 2025-05-26 07:48:33 +02:00
638e8bf20c feat: gender-separated MVPChart in mixed teams 2025-05-26 07:33:31 +02:00
a4ea0dfc41 fix: don't show times of players not in the team 2025-05-25 21:13:28 +02:00
62ba89c599 feat: require gender when registering 2025-05-23 22:09:49 +02:00
05bdc5c44c feat: support mixed teams in MVP ranking 2025-05-23 22:01:08 +02:00
105b3778e1 fix: find display_name 2025-05-23 09:44:22 +02:00
003f401320 fix: go back to forms if not team captain 2025-05-22 12:45:49 +02:00
2195e7324d feat: exclude players from analysis that aren't in the team 2025-05-22 12:33:28 +02:00
ba26e7c9e6 feat: bigger "Events" 2025-05-21 15:18:01 +02:00
64d6edd9f5 feat: order players alphabetically 2025-05-21 15:17:34 +02:00
b781408c18 feat: disable dark mode for text inputs 2025-05-21 15:08:36 +02:00
a0c8e0cd18 feat: decrease calendar size 2025-05-21 15:04:06 +02:00
de8dc6b9b9 feat: load players in session 2025-05-21 14:55:13 +02:00
241f6fa7eb feat: show active player 2025-05-21 14:37:07 +02:00
a42fff807c feat: useSession for players 2025-05-21 14:36:51 +02:00
369cf0b727 feat: calendar display for latest submissions 2025-05-21 14:26:35 +02:00
a6dfab47d5 fix: props.list empty 2025-05-19 14:59:10 +02:00
4c78ede7c2 feat: increase dnd box 2025-05-19 14:50:24 +02:00
8c8a88e72c fix: handle players removed from team 2025-05-19 14:45:31 +02:00
17 changed files with 562 additions and 478 deletions

View File

@@ -8,7 +8,15 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlmodel import Session, func, select
from sqlmodel.sql.expression import SelectOfScalar
from cutt.db import Chemistry, MVPRanking, Player, PlayerTeamLink, Team, engine
from cutt.db import (
Chemistry,
MVPRanking,
Player,
PlayerTeamLink,
PlayerType,
Team,
engine,
)
import networkx as nx
import numpy as np
import matplotlib
@@ -25,6 +33,7 @@ analysis_router = APIRouter(prefix="/analysis", tags=["analysis"])
C = Chemistry
R = MVPRanking
PT = PlayerType
P = Player
@@ -144,30 +153,37 @@ def graph_json(
)
for c in session.exec(statement2):
if c.user not in player_map:
continue
user = player_map[c.user]
for i, p_id in enumerate(c.love):
if p_id not in player_map:
continue
p = player_map[p_id]
weight = 0.9**i
edges.append(
{
"id": f"{user}->{p}",
"source": user,
"target": p,
"size": max(1.0 - 0.1 * i, 0.3),
"size": weight,
"data": {
"relation": 2,
"origSize": max(1.0 - 0.1 * i, 0.3),
"origSize": weight,
"origFill": "#bed4ff",
},
}
)
for p_id in c.hate:
if p_id not in player_map:
continue
p = player_map[p_id]
edges.append(
{
"id": f"{user}-x>{p}",
"source": user,
"target": p,
"size": 0.3,
"size": 0.5,
"data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"},
"fill": "#ff7c7c",
}
@@ -293,25 +309,67 @@ async def render_sociogram(params: Params):
return {"image": encoded_image}
def mvp(
translate_tablename = {
R.__tablename__: "🏆",
C.__tablename__: "🧪",
PT.__tablename__: "🃏",
}
def last_submissions(
request: Annotated[TeamScopedRequest, Security(verify_team_scope)],
):
ranks = dict()
times = {}
with Session(engine) as session:
player_ids = session.exec(
select(P.id)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.disabled == False)
).all()
for survey in [C, PT, R]:
subquery = (
select(survey.user, func.max(survey.time).label("latest"))
.where(survey.team == request.team_id)
.where(survey.user.in_(player_ids))
.group_by(survey.user)
.subquery()
)
statement2 = select(survey).join(
subquery,
(survey.user == subquery.c.user) & (survey.time == subquery.c.latest),
)
for r in session.exec(statement2):
if r.time.date() not in times:
times[r.time.date()] = {}
times[r.time.date()][r.user] = (
times[r.time.date()].get(r.user, "")
+ translate_tablename[survey.__tablename__]
)
return times
def mvp(
request: Annotated[TeamScopedRequest, Security(verify_team_scope)], mixed=False
):
if request.team_id == 42:
ranks = {}
random.seed(42)
players = [request.user] + demo_players
for p in players:
random.shuffle(players)
for i, p in enumerate(players):
ranks[p.display_name] = ranks.get(p.display_name, []) + [i + 1]
ranks[p.id] = ranks.get(p.id, []) + [i + 1]
return [
{
"name": p,
"rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}",
"n": len(v),
}
for p, v in ranks.items()
[
{
"p_id": p_id,
"rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}",
"n": len(v),
}
for i, (p_id, v) in enumerate(ranks.items())
]
]
with Session(engine) as session:
@@ -323,7 +381,7 @@ def mvp(
).all()
if not players:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
player_map = {p.id: p.display_name for p in players}
player_map = {p.id: p for p in players}
subquery = (
select(R.user, func.max(R.time).label("latest"))
.where(R.team == request.team_id)
@@ -333,23 +391,45 @@ def mvp(
statement2 = select(R).join(
subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest)
)
for r in session.exec(statement2):
for i, p_id in enumerate(r.mvps):
p = player_map[p_id]
ranks[p] = ranks.get(p, []) + [i + 1]
if mixed:
all_ranks = []
for gender in ["fmp", "mmp"]:
ranks = {}
for r in session.exec(statement2):
mvps = [
p_id
for p_id in r.mvps
if p_id in player_map and player_map[p_id].gender == gender
]
for i, p_id in enumerate(mvps):
p = player_map[p_id]
ranks[p_id] = ranks.get(p_id, []) + [i + 1]
all_ranks.append(ranks)
else:
ranks = {}
for r in session.exec(statement2):
for i, p_id in enumerate(r.mvps):
if p_id not in player_map:
continue
p = player_map[p_id]
ranks[p_id] = ranks.get(p_id, []) + [i + 1]
all_ranks = [ranks]
if not ranks:
if not all_ranks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="no entries found"
)
return [
{
"name": p,
"rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}",
"n": len(v),
}
for p, v in ranks.items()
[
{
"p_id": p_id,
"rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}",
"n": len(v),
}
for p_id, v in ranks.items()
]
for ranks in all_ranks
]
@@ -424,6 +504,9 @@ analysis_router.add_api_route(
description="Request Most Valuable Players stats",
)
analysis_router.add_api_route("/turnout/{team_id}", endpoint=turnout, methods=["GET"])
analysis_router.add_api_route(
"/times/{team_id}", endpoint=last_submissions, methods=["GET"]
)
if __name__ == "__main__":
with Session(engine) as session:

View File

@@ -38,6 +38,7 @@ class Team(SQLModel, table=True):
name: str
location: str | None
country: str | None
mixed: bool = False
players: list["Player"] | None = Relationship(
back_populates="teams", link_model=PlayerTeamLink
)

View File

@@ -16,7 +16,7 @@ names = [
demo_players = [
Player.model_validate(
{
"id": i,
"id": i + 4200,
"display_name": name,
"username": name.lower().replace(" ", "").replace(".", ""),
"gender": gender,

View File

@@ -188,6 +188,7 @@ async def list_players(
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == team_id, P.disabled == False)
.order_by(P.display_name)
).all()
if players:
return [

View File

@@ -1,228 +0,0 @@
import { useEffect, useState } from "react";
import { apiAuth } from "./api";
//const debounce = <T extends (...args: any[]) => void>(
// func: T,
// delay: number
//): ((...args: Parameters<T>) => void) => {
// let timeoutId: number | null = null;
// return (...args: Parameters<T>) => {
// if (timeoutId !== null) {
// clearTimeout(timeoutId);
// }
// console.log(timeoutId);
// timeoutId = setTimeout(() => {
// func(...args);
// }, delay);
// };
//};
//
interface Params {
nodeSize: number;
edgeWidth: number;
arrowSize: number;
fontSize: number;
distance: number;
weighting: boolean;
popularity: boolean;
show: number;
}
let timeoutID: NodeJS.Timeout | null = null;
export default function Analysis() {
const [image, setImage] = useState("");
const [params, setParams] = useState<Params>({
nodeSize: 2000,
edgeWidth: 1,
arrowSize: 16,
fontSize: 10,
distance: 2,
weighting: true,
popularity: true,
show: 2,
});
const [showControlPanel, setShowControlPanel] = useState(false);
const [loading, setLoading] = useState(true);
// Function to generate and fetch the graph image
async function loadImage() {
setLoading(true);
await apiAuth("analysis/image", params, "POST")
.then((data) => {
setImage(data.image);
setLoading(false);
})
.catch((e) => {
console.log("best to just reload... ", e);
});
}
useEffect(() => {
if (timeoutID) {
clearTimeout(timeoutID);
}
timeoutID = setTimeout(() => {
loadImage();
}, 1000);
}, [params]);
function showLabel() {
switch (params.show) {
case 0:
return "dislike";
case 1:
return "both";
case 2:
return "like";
}
}
return (
<div className="stack column dropdown">
<button onClick={() => setShowControlPanel(!showControlPanel)}>
Parameters{" "}
<svg
viewBox="0 0 24 24"
height="1.2em"
style={{
fill: "#ffffff",
display: "inline",
top: "0.2em",
position: "relative",
transform: showControlPanel ? "rotate(180deg)" : "unset",
}}
>
{" "}
<path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z"> </path>
</svg>
</button>
<div id="control-panel" className={showControlPanel ? "opened" : ""}>
<div className="control">
<datalist id="markers">
<option value="0"></option>
<option value="1"></option>
<option value="2"></option>
</datalist>
<div id="three-slider">
<label>😬</label>
<input
type="range"
list="markers"
min="0"
max="2"
step="1"
width="16px"
onChange={(evt) =>
setParams({ ...params, show: Number(evt.target.value) })
}
/>
<label>😍</label>
</div>
{showLabel()}
</div>
<div className="control">
<div className="checkBox">
<input
type="checkbox"
checked={params.weighting}
onChange={(evt) =>
setParams({ ...params, weighting: evt.target.checked })
}
/>
<label>weighting</label>
</div>
<div className="checkBox">
<input
type="checkbox"
checked={params.popularity}
onChange={(evt) =>
setParams({ ...params, popularity: evt.target.checked })
}
/>
<label>popularity</label>
</div>
</div>
<div className="control">
<label>distance between nodes</label>
<input
type="range"
min="0.01"
max="3.001"
step="0.05"
value={params.distance}
onChange={(evt) =>
setParams({ ...params, distance: Number(evt.target.value) })
}
/>
<span>{params.distance}</span>
</div>
<div className="control">
<label>node size</label>
<input
type="range"
min="500"
max="3000"
value={params.nodeSize}
onChange={(evt) =>
setParams({ ...params, nodeSize: Number(evt.target.value) })
}
/>
<span>{params.nodeSize}</span>
</div>
<div className="control">
<label>font size</label>
<input
type="range"
min="4"
max="24"
value={params.fontSize}
onChange={(evt) =>
setParams({ ...params, fontSize: Number(evt.target.value) })
}
/>
<span>{params.fontSize}</span>
</div>
<div className="control">
<label>edge width</label>
<input
type="range"
min="1"
max="5"
step="0.1"
value={params.edgeWidth}
onChange={(evt) =>
setParams({ ...params, edgeWidth: Number(evt.target.value) })
}
/>
<span>{params.edgeWidth}</span>
</div>
<div className="control">
<label>arrow size</label>
<input
type="range"
min="10"
max="50"
value={params.arrowSize}
onChange={(evt) =>
setParams({ ...params, arrowSize: Number(evt.target.value) })
}
/>
<span>{params.arrowSize}</span>
</div>
</div>
<button onClick={() => loadImage()}>reload </button>
{loading ? (
<span className="loader"></span>
) : (
<img src={"data:image/png;base64," + image} width="86%" />
)}
</div>
);
}

View File

@@ -154,6 +154,8 @@ select {
margin-top: 0.25em;
margin-bottom: 0.25em;
border-radius: 1em;
color: black;
background-color: white;
}
h1,
@@ -188,6 +190,7 @@ h3 {
display: flex;
flex-direction: column;
min-height: 32px;
height: 92%;
}
.box {
@@ -490,7 +493,6 @@ button {
}
select {
max-width: 335px;
background-color: white;
}
}
@@ -498,7 +500,7 @@ button {
color: black;
background-color: #36c4;
border: 1px solid black;
border-radius: 1.4em;
border-radius: 1.5em;
margin: 4px;
padding: 0.2em 0.5em;
@@ -513,12 +515,6 @@ button {
&.disable-player {
background-color: #e338;
}
&.mmp {
background-color: lightskyblue;
}
&.fmp {
background-color: salmon;
}
}
.new-player-inputs {
@@ -549,6 +545,14 @@ button {
}
}
.mmp {
background-color: lightskyblue;
}
.fmp {
background-color: salmon;
}
@keyframes blink {
0% {
background-color: #8888;
@@ -643,3 +647,88 @@ button {
transform: translateX(0%);
}
}
.calendar-container {
position: relative;
margin: 20px auto;
font-size: small;
}
.month-navigation {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-top: 2px solid grey;
border-bottom: 2px solid grey;
}
.month-navigation button {
cursor: pointer;
padding: 4px 8px;
border: none;
color: black;
background-color: transparent;
}
.month-navigation span {
font-weight: bold;
}
.calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
}
.day {
padding: 2px;
border: 1px solid grey;
cursor: pointer;
display: flex;
}
.selected-day {
border: 4px solid grey;
}
.weekday {
border-bottom: 3px solid black;
margin: 0 1em;
}
.day-circle {
text-align: center;
border-radius: 1.5em;
width: 1.5em;
height: 1.5em;
padding: 0;
margin: auto;
border: 2px solid transparent;
}
.today {
border-radius: 1.6em;
border: 4px solid red;
text-align: center;
}
.has-event {
border-radius: 1.5em;
background-color: lightskyblue;
}
.active-player {
border-radius: 1.5em;
border: 4px solid rebeccapurple;
}
.events {
font-size: large;
padding: 20px;
ul > li {
padding: 0;
margin: 0;
list-style-type: none;
}
}

View File

@@ -1,4 +1,3 @@
import Analysis from "./Analysis";
import "./App.css";
import Footer from "./Footer";
import Header from "./Header";
@@ -35,7 +34,6 @@ function App() {
<Routes>
<Route index element={<Rankings />} />
<Route path="network" element={<GraphComponent />} />
<Route path="analysis" element={<Analysis />} />
<Route path="mvp" element={<MVPChart />} />
<Route path="changepassword" element={<SetPassword />} />
<Route path="team" element={<TeamPanel />} />

View File

@@ -1,95 +0,0 @@
import { FC } from 'react';
import { PlayerRanking } from './types';
interface BarChartProps {
players: PlayerRanking[];
width: number;
height: number;
std: boolean;
}
const BarChart: FC<BarChartProps> = ({ players, width, height, std }) => {
const padding = 24;
const maxValue = Math.max(...players.map((player) => player.rank)) + 1;
const barWidth = (width - 2 * padding) / players.length;
return (
<svg width={width} height={height}>
{players.map((player, index) => (
<rect
key={index}
x={index * barWidth + padding}
y={height - (1 - player.rank / maxValue) * height}
width={barWidth - 8} // subtract 2 for some spacing between bars
height={(1 - player.rank / maxValue) * height}
fill="#69f"
/>
))}
{players.map((player, index) => (
<text
key={index}
x={index * barWidth + barWidth / 2 - 4 + padding}
y={height - (1 - player.rank / maxValue) * height - 5}
textAnchor="middle"
//transform='rotate(-27)'
//style={{ transformOrigin: "center", transformBox: "fill-box" }}
fontSize="16px"
fill="#404040"
>
{player.name}
</text>
))}
{players.map((player, index) => (
<text
key={index}
x={index * barWidth + barWidth / 2 + padding - 4}
y={height - 8}
textAnchor="middle"
fontSize="12px"
fill="#404040"
>
{player.rank}
</text>
))}
{std && players.map((player, index) => (
<line
key={`error-${index}`}
x1={index * barWidth + barWidth / 2 + padding}
y1={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height}
x2={index * barWidth + barWidth / 2 + padding}
y2={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height}
stroke="#ff0000"
strokeWidth="1"
/>
))}
{std && players.map((player, index) => (
<line
key={`cap-${index}-top`}
x1={index * barWidth + barWidth / 2 - 2 + padding}
y1={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height}
x2={index * barWidth + barWidth / 2 + 2 + padding}
y2={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height}
stroke="#ff0000"
strokeWidth="1"
/>
))}
{std && players.map((player, index) => (
<line
key={`cap-${index}-bottom`}
x1={index * barWidth + barWidth / 2 - 2 + padding}
y1={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height}
x2={index * barWidth + barWidth / 2 + 2 + padding}
y2={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height}
stroke="#ff0000"
strokeWidth="1"
/>
))}
</svg>
);
};
export default BarChart;

174
src/Calendar.tsx Normal file
View File

@@ -0,0 +1,174 @@
import { useEffect, useState } from "react";
import { apiAuth } from "./api";
import { useSession } from "./Session";
interface Datum {
[id: number]: string;
}
interface Events {
[key: string]: Datum;
}
const Calendar = ({ playerId }: { playerId: number }) => {
const [selectedDate, setSelectedDate] = useState(new Date());
const [events, setEvents] = useState<Events>();
const { teams, players } = useSession();
async function loadSubmissionDates() {
if (teams?.activeTeam) {
const data = await apiAuth(`analysis/times/${teams?.activeTeam}`, null);
if (data.detail) {
console.log(data.detail);
} else {
setEvents(data as Events);
}
}
}
useEffect(() => {
loadSubmissionDates();
}, [players]);
const getEventsForDay = (date: Date) => {
return events && events[date.toISOString().split("T")[0]];
};
// Handle day click
const handleDayClick = (date: Date) => {
setSelectedDate(date);
};
// Navigate to previous month
const handlePrevMonth = () => {
const date = new Date(selectedDate);
date.setMonth(date.getMonth() - 1);
setSelectedDate(date);
};
// Navigate to next month
const handleNextMonth = () => {
const date = new Date(selectedDate);
date.setMonth(date.getMonth() + 1);
setSelectedDate(date);
};
// Render month navigation
const renderMonthNavigation = () => {
return (
<div className="month-navigation">
<button onClick={handlePrevMonth}>&lt;</button>
<span>
<button onClick={() => setSelectedDate(new Date())}>📅</button>
{selectedDate.toLocaleString("default", {
month: "long",
year: "numeric",
})}
</span>
<button onClick={handleNextMonth}>&gt;</button>
</div>
);
};
// Render the calendar
const renderCalendar = () => {
const firstDayOfMonth = new Date(
selectedDate.getFullYear(),
selectedDate.getMonth(),
0
).getDay();
const lastDateOfMonth = new Date(
selectedDate.getFullYear(),
selectedDate.getMonth() + 1,
0
).getDate();
let days: JSX.Element[] = [];
let day = 1;
for (let i = 0; i < 7; i++) {
const date = new Date(0);
date.setDate(i + 5);
days.push(
<div key={"weekday_" + i} className="weekday">
{date.toLocaleString("default", {
weekday: "narrow",
})}
</div>
);
}
// Add empty cells for the first week
for (let i = 0; i < firstDayOfMonth; i++) {
days.push(<div key={"prev" + i} className="empty"></div>);
}
// Render each day of the month
while (day <= lastDateOfMonth) {
const date = new Date(selectedDate);
date.setDate(day);
const todaysEvents = getEventsForDay(date);
days.push(
<div
key={date.getDate()}
className={
"day" +
(date.toDateString() === selectedDate.toDateString()
? " selected-day"
: "")
}
onClick={() => handleDayClick(date)}
>
<div
className={
"day-circle" +
(date.toDateString() === new Date().toDateString()
? " today"
: "") +
(todaysEvents ? " has-event" : "") +
(todaysEvents && playerId in todaysEvents ? " active-player" : "")
}
>
{day}
</div>
</div>
);
day++;
}
return <div className="calendar">{days}</div>;
};
// Render events for the selected day
const renderEvents = () => {
const eventsForDay = getEventsForDay(selectedDate);
return (
<div className="events">
{eventsForDay && (
<ul>
{Object.entries(eventsForDay).map(([id, sub]) => {
const name = players?.find((p) => p.id === Number(id));
return (
<li key={id}>
{name !== undefined ? name.display_name : ""}:{" "}
<span style={{ letterSpacing: 8 }}>{sub}</span>
</li>
);
})}
</ul>
)}
</div>
);
};
return (
<div className="calendar-container">
<h2>Latest Submissions</h2>
{renderMonthNavigation()}
{renderCalendar()}
{renderEvents()}
</div>
);
};
export default Calendar;

View File

@@ -15,7 +15,7 @@ export default function Footer() {
</Link>
<span>|</span>
<Link to="/network">
<span>Trainer Analysis</span>
<span>Sociogram</span>
</Link>
<span>|</span>
<Link to="/mvp">

View File

@@ -6,12 +6,13 @@ import { useSession } from "./Session";
import { useNavigate } from "react-router";
const MVPChart = () => {
let initialData = {} as PlayerRanking[];
let initialData = {} as PlayerRanking[][];
const [data, setData] = useState(initialData);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showStd, setShowStd] = useState(false);
const { user, teams } = useSession();
const [mixed, setMixed] = useState(false);
const navigate = useNavigate();
useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
@@ -19,21 +20,30 @@ const MVPChart = () => {
navigate("/", { replace: true });
}, [user]);
useEffect(() => {
if (teams) {
const activeTeam = teams.teams.find(
(team) => team.id == teams.activeTeam
);
activeTeam && setMixed(activeTeam.mixed);
}
}, [teams]);
async function loadData() {
setLoading(true);
if (teams) {
await apiAuth(`analysis/mvp/${teams?.activeTeam}`, null)
await apiAuth(`analysis/mvp/${teams?.activeTeam}?mixed=${mixed}`, null)
.then((data) => {
if (data.detail) {
setError(data.detail);
return initialData;
} else {
setError("");
return data as Promise<PlayerRanking[]>;
return data as Promise<PlayerRanking[][]>;
}
})
.then((data) => {
setData(data.sort((a, b) => a.rank - b.rank));
setData(data.map((_data) => _data.sort((a, b) => a.rank - b.rank)));
})
.catch(() => setError("no access"));
setLoading(false);
@@ -46,7 +56,8 @@ const MVPChart = () => {
if (loading) return <span className="loader" />;
else if (error) return <span>{error}</span>;
else return <RaceChart std={showStd} players={data} />;
else
return data.map((_data) => <RaceChart std={showStd} playerRanks={_data} />);
};
export default MVPChart;

View File

@@ -1,8 +1,9 @@
import { FC, useEffect, useState } from "react";
import { PlayerRanking } from "./types";
import { useSession } from "./Session";
interface RaceChartProps {
players: PlayerRanking[];
playerRanks: PlayerRanking[];
std: boolean;
}
@@ -13,15 +14,14 @@ const determineNiceWidth = (width: number) => {
else return width * 0.96;
};
const RaceChart: FC<RaceChartProps> = ({ players, std }) => {
const RaceChart: FC<RaceChartProps> = ({ playerRanks, std }) => {
const [width, setWidth] = useState(determineNiceWidth(window.innerWidth));
//const [height, setHeight] = useState(window.innerHeight);
const height = (players.length + 1) * 40;
const height = (playerRanks.length + 1) * 40;
const { players } = useSession();
useEffect(() => {
const handleResize = () => {
setWidth(determineNiceWidth(window.innerWidth));
//setHeight(window.innerHeight);
};
window.addEventListener("resize", handleResize);
return () => {
@@ -30,18 +30,18 @@ const RaceChart: FC<RaceChartProps> = ({ players, std }) => {
}, []);
const padding = 24;
const gap = 8;
const maxValue = Math.max(...players.map((player) => player.rank)) + 1;
const barHeight = (height - 2 * padding) / players.length;
const maxValue = Math.max(...playerRanks.map((player) => player.rank)) + 1;
const barHeight = (height - 2 * padding) / playerRanks.length;
const fontSize = Math.min(barHeight - 1.5 * gap, width / 22);
return (
<svg width={width} height={height} id="RaceChartSVG">
{players.map((player, index) => (
{playerRanks.map((playerRank, index) => (
<rect
key={String(index)}
x={4}
y={index * barHeight + padding}
width={(1 - player.rank / maxValue) * width}
width={(1 - playerRank.rank / maxValue) * width}
height={barHeight - gap} // subtract 2 for some spacing between bars
fill="#36c"
stroke="aliceblue"
@@ -50,49 +50,53 @@ const RaceChart: FC<RaceChartProps> = ({ players, std }) => {
/>
))}
{players.map((player, index) => (
<g key={"group" + index}>
<text
key={index + "_name"}
x={8}
y={index * barHeight + barHeight / 2 + padding + gap / 2}
width={(1 - player.rank / maxValue) * width}
height={barHeight - 8} // subtract 2 for some spacing between bars
fontSize={fontSize}
fill="aliceblue"
stroke="#36c"
strokeWidth={4}
fontWeight={"bold"}
paintOrder={"stroke fill"}
fontFamily="monospace"
style={{ whiteSpace: "pre" }}
>
{`${String(index + 1).padStart(2)}. ${player.name}`}
</text>
<text
key={index + "_value"}
x={
8 +
(4 + Math.max(...players.map((p, _) => p.name.length))) *
fontSize *
0.66
}
y={index * barHeight + barHeight / 2 + padding + gap / 2}
width={(1 - player.rank / maxValue) * width}
height={barHeight - 8} // subtract 2 for some spacing between bars
fontSize={0.8 * fontSize}
fill="aliceblue"
stroke="#36c"
fontWeight={"bold"}
fontFamily="monospace"
strokeWidth={4}
paintOrder={"stroke fill"}
style={{ whiteSpace: "pre" }}
>
{`${String(player.rank).padStart(5)} ± ${player.std} N = ${player.n}`}
</text>
</g>
))}
{playerRanks.map((playerRank, index) => {
const player = players!.find((p) => p.id === playerRank.p_id);
return (
<g key={"group" + index}>
<text
key={index + "_name"}
x={8}
y={index * barHeight + barHeight / 2 + padding + gap / 2}
width={(1 - playerRank.rank / maxValue) * width}
height={barHeight - 8} // subtract 2 for some spacing between bars
fontSize={fontSize}
fill="aliceblue"
stroke="#36c"
strokeWidth={4}
fontWeight={"bold"}
paintOrder={"stroke fill"}
fontFamily="monospace"
style={{ whiteSpace: "pre" }}
>
{`${String(index + 1).padStart(2)}. ${player?.display_name}`}
</text>
<text
key={index + "_value"}
x={
8 +
(4 +
Math.max(...players!.map((p, _) => p.display_name.length))) *
fontSize *
0.66
}
y={index * barHeight + barHeight / 2 + padding + gap / 2}
width={(1 - playerRank.rank / maxValue) * width}
height={barHeight - 8} // subtract 2 for some spacing between bars
fontSize={0.8 * fontSize}
fill="aliceblue"
stroke="#36c"
fontWeight={"bold"}
fontFamily="monospace"
strokeWidth={4}
paintOrder={"stroke fill"}
style={{ whiteSpace: "pre" }}
>
{`${String(playerRank.rank).padStart(5)} ± ${playerRank.std} N = ${playerRank.n}`}
</text>
</g>
);
})}
</svg>
);
};

View File

@@ -1,29 +1,41 @@
import { ButtonHTMLAttributes, useEffect, useRef, useState } from "react";
import { ReactSortable, ReactSortableProps } from "react-sortablejs";
import { apiAuth, loadPlayers, User } from "./api";
import { apiAuth, User } from "./api";
import { TeamState, useSession } from "./Session";
import { Chemistry, MVPRanking, PlayerType } from "./types";
import TabController from "./TabController";
type PlayerListProps = Partial<ReactSortableProps<any>> & {
orderedList?: boolean;
gender?: boolean;
};
function PlayerList(props: PlayerListProps) {
const fmps = props.list?.filter((item) => item.gender === "fmp").length;
return (
<ReactSortable
{...props}
animation={200}
swapThreshold={0.2}
style={{ minHeight: props.list?.length < 1 ? 64 : 32 }}
style={{ minHeight: props.list && props.list?.length < 1 ? 64 : 32 }}
>
{props.list?.map((item, index) => (
<div key={item.id} className="item">
{props.orderedList
? index + 1 + ". " + item.display_name
: item.display_name}
</div>
))}
{props.list &&
props.list.map((item, index) => (
<div
key={item.id}
className={"item " + (props.gender ? item.gender : "")}
>
{props.orderedList
? props.gender
? index +
1 -
(item.gender !== "fmp" ? fmps! : 0) +
". " +
item.display_name
: index + 1 + ". " + item.display_name
: item.display_name}
</div>
))}
</ReactSortable>
);
}
@@ -325,10 +337,18 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [mixed, setMixed] = useState(false);
useEffect(() => {
const activeTeam = teams.teams.find((team) => team.id == teams.activeTeam);
activeTeam && setMixed(activeTeam.mixed);
handleGet();
}, [players]);
useEffect(() => {
handleGet();
}, [players]);
// setMixedList(rankedPlayers);
}, [mixed]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
@@ -342,6 +362,15 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
response ? setDialog(response) : setDialog("try sending again");
}
const setMixedList = (newList: User[]) =>
mixed
? setRankedPlayers(
newList.sort((a, b) =>
a.gender && b.gender ? a.gender.localeCompare(b.gender) : -1
)
)
: setRankedPlayers(newList);
async function handleGet() {
setLoading(true);
const data = await apiAuth(`mvps/${teams.activeTeam}`, null, "GET");
@@ -351,7 +380,7 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
setRankedPlayers([]);
} else {
const mvps = data as MVPRanking;
setRankedPlayers(filterSort(players, mvps.mvps));
setMixedList(filterSort(players, mvps.mvps));
setAvailablePlayers(
players.filter((user) => !mvps.mvps.includes(user.id))
);
@@ -382,11 +411,9 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
setList={setAvailablePlayers}
group={{
name: "mvp-shared",
pull: function (to) {
return to.el.classList.contains("putclone") ? "clone" : true;
},
}}
className="dragbox"
gender={mixed}
/>
</div>
<div className="box two">
@@ -399,15 +426,13 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
)}
<PlayerList
list={rankedPlayers}
setList={setRankedPlayers}
setList={setMixedList}
group={{
name: "mvp-shared",
pull: function (to) {
return to.el.classList.contains("putclone") ? "clone" : true;
},
}}
className="dragbox"
orderedList
gender={mixed}
/>
</div>
</div>
@@ -451,14 +476,7 @@ function HeaderControl({ onLoad, onClear }: HeaderControlProps) {
}
export default function Rankings() {
const { user, teams } = useSession();
const [players, setPlayers] = useState<User[] | null>(null);
useEffect(() => {
if (teams) {
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}, [user, teams]);
const { user, teams, players } = useSession();
const tabs = [
{ id: "Chemistry", label: "🧪 Chemistry" },

View File

@@ -5,7 +5,7 @@ import {
useEffect,
useState,
} from "react";
import { apiAuth, currentUser, logout, User } from "./api";
import { apiAuth, currentUser, loadPlayers, logout, User } from "./api";
import { Login } from "./Login";
import Header from "./Header";
import { Team } from "./types";
@@ -23,6 +23,8 @@ export interface Session {
user: User | null;
teams: TeamState | null;
setTeams: (teams: TeamState) => void;
players: User[] | null;
reloadPlayers: () => void;
onLogout: () => void;
}
@@ -30,6 +32,8 @@ const sessionContext = createContext<Session>({
user: null,
teams: null,
setTeams: () => {},
players: null,
reloadPlayers: () => {},
onLogout: () => {},
});
@@ -38,6 +42,7 @@ export function SessionProvider(props: SessionProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [teams, setTeams] = useState<TeamState | null>(null);
const [players, setPlayers] = useState<User[] | null>(null);
const [err, setErr] = useState<unknown>(null);
const [loading, setLoading] = useState(false);
@@ -60,12 +65,19 @@ export function SessionProvider(props: SessionProviderProps) {
if (teams) setTeams({ teams: teams, activeTeam: teams[0].id });
}
async function reloadPlayers() {
teams && loadPlayers(teams?.activeTeam).then((data) => setPlayers(data));
}
useEffect(() => {
loadUser();
}, []);
useEffect(() => {
loadTeam();
}, [user]);
useEffect(() => {
reloadPlayers();
}, [teams]);
function onLogin(user: User) {
setUser(user);
@@ -96,7 +108,9 @@ export function SessionProvider(props: SessionProviderProps) {
content = <Login onLogin={onLogin} />;
} else
content = (
<sessionContext.Provider value={{ user, teams, setTeams, onLogout }}>
<sessionContext.Provider
value={{ user, teams, setTeams, players, reloadPlayers, onLogout }}
>
{children}
</sessionContext.Provider>
);

View File

@@ -1,6 +1,6 @@
import { jwtDecode, JwtPayload } from "jwt-decode";
import { ReactNode, useEffect, useState } from "react";
import { apiAuth, baseUrl, User } from "./api";
import { apiAuth, baseUrl, Gender, User } from "./api";
import { useNavigate } from "react-router";
import { Eye, EyeSlash } from "./Icons";
import { useSession } from "./Session";
@@ -237,6 +237,21 @@ export const SetPassword = () => {
}}
/>
</div>
<div>
<label>gender</label>
<select
name="gender"
required
value={player.gender}
onChange={(e) => {
setPlayer({ ...player, gender: e.target.value as Gender });
}}
>
<option value={undefined}></option>
<option value="fmp">FMP</option>
<option value="mmp">MMP</option>
</select>
</div>
<div>
<label>number (optional)</label>
<input

View File

@@ -1,17 +1,18 @@
import { FormEvent, useEffect, useState } from "react";
import { apiAuth, Gender, loadPlayers, User } from "./api";
import { apiAuth, Gender, User } from "./api";
import { useSession } from "./Session";
import { ErrorState } from "./types";
import { useNavigate } from "react-router";
import Calendar from "./Calendar";
const TeamPanel = () => {
const { user, teams } = useSession();
const { user, teams, players, reloadPlayers } = useSession();
const navigate = useNavigate();
useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
teams?.activeTeam === 42 ||
navigate("/", { replace: true });
}, [user]);
}, [user, teams]);
const newPlayerTemplate = {
id: 0,
username: "",
@@ -21,16 +22,8 @@ const TeamPanel = () => {
email: "",
} as User;
const [error, setError] = useState<ErrorState>();
const [players, setPlayers] = useState<User[] | null>(null);
const [player, setPlayer] = useState(newPlayerTemplate);
useEffect(() => {
if (teams) {
setError({ ok: true, message: "" });
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}, [teams]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (teams) {
@@ -39,14 +32,14 @@ const TeamPanel = () => {
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
reloadPlayers();
}
} else {
const r = await apiAuth(`player/${teams?.activeTeam}`, player, "PUT");
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
reloadPlayers();
}
}
}
@@ -66,7 +59,7 @@ const TeamPanel = () => {
else {
setError({ ok: true, message: r });
setPlayer(newPlayerTemplate);
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
reloadPlayers();
}
}
}
@@ -94,19 +87,22 @@ const TeamPanel = () => {
justifyContent: "center",
}}
>
{players &&
players.map((p) => (
<button
className={"team-player " + p.gender}
key={p.id}
onClick={() => {
setPlayer(p);
setError({ ok: true, message: "" });
}}
>
{p.display_name}
</button>
))}
{players.map((p) => (
<button
className={
"team-player " +
p.gender +
(p.id === player.id ? " active-player" : "")
}
key={p.id}
onClick={() => {
setPlayer(p);
setError({ ok: true, message: "" });
}}
>
{p.display_name}
</button>
))}
<button
className="team-player new-player"
key="add-player"
@@ -133,8 +129,10 @@ const TeamPanel = () => {
onChange={(e) => {
setPlayer({
...player,
...(player.id === 0 && {
username: e.target.value.toLowerCase().replace(/\W/g, ""),
}),
display_name: e.target.value,
username: e.target.value.toLowerCase().replace(/\W/g, ""),
});
setError({ ok: true, message: "" });
}}
@@ -157,7 +155,6 @@ const TeamPanel = () => {
<label>gender</label>
<select
name="gender"
required
value={player.gender}
onChange={(e) => {
setPlayer({ ...player, gender: e.target.value as Gender });
@@ -219,6 +216,7 @@ const TeamPanel = () => {
)}
</form>
</div>
<Calendar playerId={player.id} />
</div>
);
} else <span className="loader" />;

View File

@@ -13,7 +13,7 @@ export default interface NetworkData {
}
export interface PlayerRanking {
name: string;
p_id: number;
rank: number;
std: number;
n: number;
@@ -46,6 +46,7 @@ export interface Team {
name: string;
location: string;
country: string;
mixed: boolean;
}
export type ErrorState = {