Compare commits

...

4 Commits

Author SHA1 Message Date
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
7 changed files with 336 additions and 29 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
@ -297,6 +306,39 @@ async def render_sociogram(params: Params):
return {"image": encoded_image}
translate_tablename = {
R.__tablename__: "🏆",
C.__tablename__: "🧪",
PT.__tablename__: "🃏",
}
def last_submissions(
request: Annotated[TeamScopedRequest, Security(verify_team_scope)],
):
times = {}
with Session(engine) as session:
for survey in [C, PT, R]:
subquery = (
select(survey.user, func.max(survey.time).label("latest"))
.where(survey.team == request.team_id)
.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)],
):
@ -430,6 +472,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

@ -499,7 +499,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;
@ -644,3 +644,85 @@ button {
transform: translateX(0%);
}
}
.calendar-container {
position: relative;
margin: 20px auto;
}
.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: 8px;
border: 1px solid grey;
cursor: pointer;
}
.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 {
padding: 20px;
ul > li {
padding: 0;
margin: 0;
list-style-type: none;
}
}

173
src/Calendar.tsx Normal file
View File

@ -0,0 +1,173 @@
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: "short",
})}
</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?.filter((p) => p.id === Number(id));
return (
<li key={id}>
{name ? name[0].display_name : ""}:{" "}
<span style={{ letterSpacing: 8 }}>{sub}</span>
</li>
);
})}
</ul>
)}
</div>
);
};
return (
<div className="calendar-container">
{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

@ -1,6 +1,6 @@
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";
@ -451,14 +451,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,11 +1,12 @@
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}`) ||
@ -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();
}
}
}
@ -97,7 +90,11 @@ const TeamPanel = () => {
{players &&
players.map((p) => (
<button
className={"team-player " + p.gender}
className={
"team-player " +
p.gender +
(p.id === player.id ? " active-player" : "")
}
key={p.id}
onClick={() => {
setPlayer(p);
@ -133,8 +130,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: "" });
}}
@ -219,6 +218,7 @@ const TeamPanel = () => {
)}
</form>
</div>
<Calendar playerId={player.id} />
</div>
);
} else <span className="loader" />;