3 Commits

Author SHA1 Message Date
df84c798be show submissions for selected player 2026-01-29 23:32:57 +01:00
052065acf9 display player type in Sociogram in RGB 2026-01-24 11:42:20 +01:00
039b43be8e fix type warning 2026-01-03 17:04:58 +01:00
7 changed files with 194 additions and 34 deletions

View File

@@ -126,6 +126,7 @@ def graph_json(
return G return G
return JSONResponse({"nodes": nodes, "edges": edges}) return JSONResponse({"nodes": nodes, "edges": edges})
playertypes = playertype(request)
with Session(engine) as session: with Session(engine) as session:
players = session.exec( players = session.exec(
select(P) select(P)
@@ -140,7 +141,18 @@ def graph_json(
) )
for p in players: for p in players:
player_map[p.id] = p.display_name player_map[p.id] = p.display_name
nodes.append({"id": p.display_name, "label": p.display_name}) playertype_colour = "#%02x%02x%02x" % (
int(playertypes[p.id]["handler"] * 255),
int(playertypes[p.id]["combi"] * 255),
int(playertypes[p.id]["cutter"] * 255),
)
nodes.append(
{
"id": p.display_name,
"label": p.display_name,
"data": {"playertype": playertype_colour},
}
)
subquery = ( subquery = (
select(C.user, func.max(C.time).label("latest")) select(C.user, func.max(C.time).label("latest"))
@@ -207,7 +219,8 @@ def graph_json(
) )
in_degrees = G.in_degree(weight="weight") in_degrees = G.in_degree(weight="weight")
nodes = [ nodes = [
dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}}) for node in nodes dict(node, **{"data": {"inDegree": in_degrees[node["id"]], **node["data"]}})
for node in nodes
] ]
if networkx_graph: if networkx_graph:
return G return G
@@ -434,6 +447,52 @@ def mvp(
] ]
def playertype(request: Annotated[TeamScopedRequest, Security(verify_team_scope)]):
with Session(engine) as session:
players = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.disabled == False)
).all()
if not players:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
player_map = {p.id: p for p in players}
subquery = (
select(PT.user, func.max(PT.time).label("latest"))
.where(PT.team == request.team_id)
.group_by(PT.user)
.subquery()
)
statement2 = select(PT).join(
subquery, (PT.user == subquery.c.user) & (PT.time == subquery.c.latest)
)
playertypes = {}
for pt in session.exec(statement2):
for i, p_id in enumerate(pt.handlers):
if p_id not in player_map:
continue
playertypes[p_id] = playertypes.get(p_id, []) + [-1]
for i, p_id in enumerate(pt.combis):
if p_id not in player_map:
continue
playertypes[p_id] = playertypes.get(p_id, []) + [0]
for i, p_id in enumerate(pt.cutters):
if p_id not in player_map:
continue
playertypes[p_id] = playertypes.get(p_id, []) + [1]
playertype_analysis = {}
for p_id, v in playertypes.items():
v = np.array(v)
playertype_analysis[p_id] = {
"mean": np.mean(v),
"handler": (v == -1).sum() / len(v),
"combi": (v == 0).sum() / len(v),
"cutter": (v == 1).sum() / len(v),
}
return playertype_analysis
async def turnout( async def turnout(
request: Annotated[ request: Annotated[
TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"]) TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"])
@@ -504,6 +563,9 @@ analysis_router.add_api_route(
name="MVPs", name="MVPs",
description="Request Most Valuable Players stats", description="Request Most Valuable Players stats",
) )
analysis_router.add_api_route(
"/playertype/{team_id}", endpoint=playertype, methods=["GET"]
)
analysis_router.add_api_route("/turnout/{team_id}", endpoint=turnout, methods=["GET"]) analysis_router.add_api_route("/turnout/{team_id}", endpoint=turnout, methods=["GET"])
analysis_router.add_api_route( analysis_router.add_api_route(
"/times/{team_id}", endpoint=last_submissions, methods=["GET"] "/times/{team_id}", endpoint=last_submissions, methods=["GET"]

View File

@@ -1,34 +1,18 @@
import { JSX, useEffect, useState } from "react"; import { JSX, useEffect, useState } from "react";
import { apiAuth } from "./api"; import { apiAuth } from "./api";
import { useSession } from "./Session"; import { useSession } from "./Session";
import { Events } from "./types";
interface Datum { const Calendar = ({
[id: number]: string; playerId,
} events,
interface Events { }: {
[key: string]: Datum; playerId: number;
} events: Events | undefined;
}) => {
const Calendar = ({ playerId }: { playerId: number }) => {
const [selectedDate, setSelectedDate] = useState(new Date()); const [selectedDate, setSelectedDate] = useState(new Date());
const [events, setEvents] = useState<Events>();
const { teams, players } = useSession(); 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) => { const getEventsForDay = (date: Date) => {
return events && events[date.toISOString().split("T")[0]]; return events && events[date.toISOString().split("T")[0]];
}; };

View File

@@ -40,7 +40,7 @@ export const Join = () => {
); );
if (r.detail) setError(r.detail); if (r.detail) setError(r.detail);
else { else {
setTeams({ ...teams, activeTeam: teamID }); teamID && teams && setTeams({ ...teams, activeTeam: teamID });
navigate("/", { replace: true }); navigate("/", { replace: true });
} }
} }

View File

@@ -49,6 +49,7 @@ export const GraphComponent = () => {
const [showLikes, setShowLikes] = useState(true); const [showLikes, setShowLikes] = useState(true);
const [showDislikes, setShowDislikes] = useState(false); const [showDislikes, setShowDislikes] = useState(false);
const [popularity, setPopularity] = useState(false); const [popularity, setPopularity] = useState(false);
const [showPlayerType, setShowPlayerType] = useState(false);
const [mutuality, setMutuality] = useState(false); const [mutuality, setMutuality] = useState(false);
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const { user, teams } = useSession(); const { user, teams } = useSession();
@@ -95,6 +96,10 @@ export const GraphComponent = () => {
popularityLabel(!popularity); popularityLabel(!popularity);
setPopularity(!popularity); setPopularity(!popularity);
} }
function handlePlayerType() {
playerType(!showPlayerType);
setShowPlayerType(!showPlayerType);
}
function handleMutuality() { function handleMutuality() {
colorMatches(!mutuality); colorMatches(!mutuality);
@@ -138,13 +143,25 @@ export const GraphComponent = () => {
setData({ nodes: data.nodes, edges: newEdges }); setData({ nodes: data.nodes, edges: newEdges });
} }
function playerType(popularity: boolean) {
const newNodes = data.nodes;
if (popularity) {
newNodes.forEach((node) => {
node.fill = node.data.playertype;
});
} else {
newNodes.forEach((node) => (node.fill = undefined));
}
setData({ nodes: newNodes, edges: data.edges });
}
function popularityLabel(popularity: boolean) { function popularityLabel(popularity: boolean) {
const newNodes = data.nodes; const newNodes = data.nodes;
console.log(data.nodes);
if (popularity) { if (popularity) {
newNodes.forEach( newNodes.forEach((node) => {
(node) => (node.subLabel = `pop.: ${node.data.inDegree.toFixed(1)}`) node.subLabel = `pop.: ${node.data.inDegree.toFixed(1)}`;
); node.fill = node.data.playertype;
});
} else { } else {
newNodes.forEach((node) => (node.subLabel = undefined)); newNodes.forEach((node) => (node.subLabel = undefined));
} }
@@ -305,6 +322,51 @@ export const GraphComponent = () => {
<span className="ml-1">popularity</span> <span className="ml-1">popularity</span>
</label> </label>
</div> </div>
<div className="control">
<label className="checkbox">
<input
type="checkbox"
checked={showPlayerType}
onClick={handlePlayerType}
/>
<span className="ml-1">player type</span>
</label>
</div>
<div className="control pl-4">
{showPlayerType && (
<>
<p>RGB:</p>
<p
style={{
color: "red",
fontWeight: "bold",
paddingLeft: "1rem",
}}
>
handler
</p>
<p
style={{
color: "green",
fontWeight: "bold",
paddingLeft: "1rem",
}}
>
combi
</p>
<p
style={{
color: "blue",
fontWeight: "bold",
paddingLeft: "1rem",
}}
>
cutter
</p>
</>
)}
</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,7 +1,7 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { apiAuth, Gender, User } from "./api"; import { apiAuth, Gender, User } from "./api";
import { useSession } from "./Session"; import { useSession } from "./Session";
import { ErrorState } from "./types"; import { ErrorState, Events } from "./types";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import Calendar from "./Calendar"; import Calendar from "./Calendar";
import { Info, Star, StarHalf, StarOff, UserPen } from "lucide-react"; import { Info, Star, StarHalf, StarOff, UserPen } from "lucide-react";
@@ -9,12 +9,30 @@ import Loading from "./Loading";
const TeamPanel = () => { const TeamPanel = () => {
const { user, teams, players, reloadPlayers } = useSession(); const { user, teams, players, reloadPlayers } = useSession();
const [events, setEvents] = useState<Events>();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) || user?.scopes.includes(`team:${teams?.activeTeam}`) ||
teams?.activeTeam === 42 || teams?.activeTeam === 42 ||
navigate("/", { replace: true }); navigate("/", { replace: true });
}, [user, teams]); }, [user, teams]);
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 newPlayerTemplate = { const newPlayerTemplate = {
id: 0, id: 0,
username: "", username: "",
@@ -27,6 +45,16 @@ const TeamPanel = () => {
const [error, setError] = useState<ErrorState>(); const [error, setError] = useState<ErrorState>();
const [player, setPlayer] = useState(newPlayerTemplate); const [player, setPlayer] = useState(newPlayerTemplate);
const getPlayerSubmissions = () => {
var submissions = [];
if (events) {
for (const [date, obj] of Object.entries(events))
for (const [id, emoji] of Object.entries(obj))
if (player.id === Number(id)) submissions.push({ emoji, date });
}
return submissions;
};
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
if (teams) { if (teams) {
@@ -252,6 +280,21 @@ const TeamPanel = () => {
</button> </button>
</div> </div>
</div> </div>
{player.id !== 0 && (
<div className="field">
<div className="control">
<label className="label">submissions</label>
{getPlayerSubmissions().map((submission) => (
<span className="tooltip">
{submission.emoji}
<span className="tooltiptext notification is-primary is-light has-text-centered">
{submission.date}
</span>
</span>
))}
</div>
</div>
)}
</div> </div>
{error?.message && ( {error?.message && (
<p <p
@@ -287,7 +330,7 @@ const TeamPanel = () => {
</section> </section>
<section className="section"> <section className="section">
<div className="container"> <div className="container">
<Calendar playerId={player.id} /> <Calendar playerId={player.id} events={events} />
</div> </div>
</section> </section>
</> </>

View File

@@ -30,7 +30,9 @@
/* Tooltip text */ /* Tooltip text */
.tooltiptext { .tooltiptext {
visibility: hidden; visibility: hidden;
width: 10rem; top: 1.5rem;
left: -0.5rem;
width: 8rem;
padding: 0.25rem; padding: 0.25rem;
position: absolute; position: absolute;
z-index: 1; z-index: 1;

View File

@@ -53,3 +53,10 @@ export type ErrorState = {
ok: boolean; ok: boolean;
message: string; message: string;
}; };
export interface Datum {
[id: number]: string;
}
export interface Events {
[key: string]: Datum;
}