diff --git a/cutt/analysis.py b/cutt/analysis.py index f5524ae..b38bc35 100644 --- a/cutt/analysis.py +++ b/cutt/analysis.py @@ -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: diff --git a/src/App.css b/src/App.css index 50a01f9..e4d3120 100644 --- a/src/App.css +++ b/src/App.css @@ -644,3 +644,75 @@ button { transform: translateX(0%); } } + +.calendar-container { + position: relative; + margin: auto; +} + +.month-navigation { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px; + border-bottom: 1px solid #ddd; +} + +.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; +} + +.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; + } +} diff --git a/src/Calendar.tsx b/src/Calendar.tsx new file mode 100644 index 0000000..31d21d8 --- /dev/null +++ b/src/Calendar.tsx @@ -0,0 +1,168 @@ +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(); + 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 ( +
+ + + + {selectedDate.toLocaleString("default", { + month: "long", + year: "numeric", + })} + + +
+ ); + }; + + // 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( +
+ {date.toLocaleString("default", { + weekday: "short", + })} +
+ ); + } + + // Add empty cells for the first week + for (let i = 0; i < firstDayOfMonth; i++) { + days.push(
); + } + + // Render each day of the month + while (day <= lastDateOfMonth) { + const date = new Date(selectedDate); + date.setDate(day); + const todaysEvents = getEventsForDay(date); + + days.push( +
handleDayClick(date)} + > +
+ {day} +
+
+ ); + day++; + } + + return
{days}
; + }; + + // Render events for the selected day + const renderEvents = () => { + const eventsForDay = getEventsForDay(selectedDate); + return ( +
+ {eventsForDay && ( + + )} +
+ ); + }; + + return ( +
+ {renderMonthNavigation()} + {renderCalendar()} + {renderEvents()} +
+ ); +}; + +export default Calendar; diff --git a/src/Session.tsx b/src/Session.tsx index 3774e68..ffed150 100644 --- a/src/Session.tsx +++ b/src/Session.tsx @@ -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,7 @@ export interface Session { user: User | null; teams: TeamState | null; setTeams: (teams: TeamState) => void; + players: User[] | null; onLogout: () => void; } @@ -30,6 +31,7 @@ const sessionContext = createContext({ user: null, teams: null, setTeams: () => {}, + players: null, onLogout: () => {}, }); @@ -38,6 +40,7 @@ export function SessionProvider(props: SessionProviderProps) { const [user, setUser] = useState(null); const [teams, setTeams] = useState(null); + const [players, setPlayers] = useState(null); const [err, setErr] = useState(null); const [loading, setLoading] = useState(false); @@ -66,6 +69,9 @@ export function SessionProvider(props: SessionProviderProps) { useEffect(() => { loadTeam(); }, [user]); + useEffect(() => { + teams && loadPlayers(teams?.activeTeam).then((data) => setPlayers(data)); + }, [teams]); function onLogin(user: User) { setUser(user); @@ -96,7 +102,9 @@ export function SessionProvider(props: SessionProviderProps) { content = ; } else content = ( - + {children} ); diff --git a/src/TeamPanel.tsx b/src/TeamPanel.tsx index 85c100c..0feaac2 100644 --- a/src/TeamPanel.tsx +++ b/src/TeamPanel.tsx @@ -3,6 +3,7 @@ import { apiAuth, Gender, loadPlayers, 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(); @@ -97,7 +98,11 @@ const TeamPanel = () => { {players && players.map((p) => (