feat: calendar display for latest submissions
This commit is contained in:
parent
a6dfab47d5
commit
369cf0b727
@ -8,7 +8,15 @@ from fastapi.responses import JSONResponse
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlmodel import Session, func, select
|
from sqlmodel import Session, func, select
|
||||||
from sqlmodel.sql.expression import SelectOfScalar
|
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 networkx as nx
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import matplotlib
|
import matplotlib
|
||||||
@ -25,6 +33,7 @@ analysis_router = APIRouter(prefix="/analysis", tags=["analysis"])
|
|||||||
|
|
||||||
C = Chemistry
|
C = Chemistry
|
||||||
R = MVPRanking
|
R = MVPRanking
|
||||||
|
PT = PlayerType
|
||||||
P = Player
|
P = Player
|
||||||
|
|
||||||
|
|
||||||
@ -297,6 +306,39 @@ async def render_sociogram(params: Params):
|
|||||||
return {"image": encoded_image}
|
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(
|
def mvp(
|
||||||
request: Annotated[TeamScopedRequest, Security(verify_team_scope)],
|
request: Annotated[TeamScopedRequest, Security(verify_team_scope)],
|
||||||
):
|
):
|
||||||
@ -430,6 +472,9 @@ analysis_router.add_api_route(
|
|||||||
description="Request Most Valuable Players stats",
|
description="Request Most Valuable Players stats",
|
||||||
)
|
)
|
||||||
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(
|
||||||
|
"/times/{team_id}", endpoint=last_submissions, methods=["GET"]
|
||||||
|
)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
|
72
src/App.css
72
src/App.css
@ -644,3 +644,75 @@ button {
|
|||||||
transform: translateX(0%);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
168
src/Calendar.tsx
Normal file
168
src/Calendar.tsx
Normal file
@ -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<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}><</button>
|
||||||
|
<span>
|
||||||
|
<button onClick={() => setSelectedDate(new Date())}>📅</button>
|
||||||
|
{selectedDate.toLocaleString("default", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<button onClick={handleNextMonth}>></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="empty">
|
||||||
|
{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"
|
||||||
|
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;
|
@ -5,7 +5,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { apiAuth, currentUser, logout, User } from "./api";
|
import { apiAuth, currentUser, loadPlayers, logout, User } from "./api";
|
||||||
import { Login } from "./Login";
|
import { Login } from "./Login";
|
||||||
import Header from "./Header";
|
import Header from "./Header";
|
||||||
import { Team } from "./types";
|
import { Team } from "./types";
|
||||||
@ -23,6 +23,7 @@ export interface Session {
|
|||||||
user: User | null;
|
user: User | null;
|
||||||
teams: TeamState | null;
|
teams: TeamState | null;
|
||||||
setTeams: (teams: TeamState) => void;
|
setTeams: (teams: TeamState) => void;
|
||||||
|
players: User[] | null;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,6 +31,7 @@ const sessionContext = createContext<Session>({
|
|||||||
user: null,
|
user: null,
|
||||||
teams: null,
|
teams: null,
|
||||||
setTeams: () => {},
|
setTeams: () => {},
|
||||||
|
players: null,
|
||||||
onLogout: () => {},
|
onLogout: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -38,6 +40,7 @@ export function SessionProvider(props: SessionProviderProps) {
|
|||||||
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [teams, setTeams] = useState<TeamState | null>(null);
|
const [teams, setTeams] = useState<TeamState | null>(null);
|
||||||
|
const [players, setPlayers] = useState<User[] | null>(null);
|
||||||
const [err, setErr] = useState<unknown>(null);
|
const [err, setErr] = useState<unknown>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@ -66,6 +69,9 @@ export function SessionProvider(props: SessionProviderProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTeam();
|
loadTeam();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
useEffect(() => {
|
||||||
|
teams && loadPlayers(teams?.activeTeam).then((data) => setPlayers(data));
|
||||||
|
}, [teams]);
|
||||||
|
|
||||||
function onLogin(user: User) {
|
function onLogin(user: User) {
|
||||||
setUser(user);
|
setUser(user);
|
||||||
@ -96,7 +102,9 @@ export function SessionProvider(props: SessionProviderProps) {
|
|||||||
content = <Login onLogin={onLogin} />;
|
content = <Login onLogin={onLogin} />;
|
||||||
} else
|
} else
|
||||||
content = (
|
content = (
|
||||||
<sessionContext.Provider value={{ user, teams, setTeams, onLogout }}>
|
<sessionContext.Provider
|
||||||
|
value={{ user, teams, setTeams, players, onLogout }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</sessionContext.Provider>
|
</sessionContext.Provider>
|
||||||
);
|
);
|
||||||
|
@ -3,6 +3,7 @@ import { apiAuth, Gender, loadPlayers, User } from "./api";
|
|||||||
import { useSession } from "./Session";
|
import { useSession } from "./Session";
|
||||||
import { ErrorState } from "./types";
|
import { ErrorState } from "./types";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
import Calendar from "./Calendar";
|
||||||
|
|
||||||
const TeamPanel = () => {
|
const TeamPanel = () => {
|
||||||
const { user, teams } = useSession();
|
const { user, teams } = useSession();
|
||||||
@ -97,7 +98,11 @@ const TeamPanel = () => {
|
|||||||
{players &&
|
{players &&
|
||||||
players.map((p) => (
|
players.map((p) => (
|
||||||
<button
|
<button
|
||||||
className={"team-player " + p.gender}
|
className={
|
||||||
|
"team-player " +
|
||||||
|
p.gender +
|
||||||
|
(p.id === player.id ? " active-player" : "")
|
||||||
|
}
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPlayer(p);
|
setPlayer(p);
|
||||||
@ -133,8 +138,10 @@ const TeamPanel = () => {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPlayer({
|
setPlayer({
|
||||||
...player,
|
...player,
|
||||||
|
...(player.id === 0 && {
|
||||||
|
username: e.target.value.toLowerCase().replace(/\W/g, ""),
|
||||||
|
}),
|
||||||
display_name: e.target.value,
|
display_name: e.target.value,
|
||||||
username: e.target.value.toLowerCase().replace(/\W/g, ""),
|
|
||||||
});
|
});
|
||||||
setError({ ok: true, message: "" });
|
setError({ ok: true, message: "" });
|
||||||
}}
|
}}
|
||||||
@ -219,6 +226,7 @@ const TeamPanel = () => {
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<Calendar playerId={player.id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else <span className="loader" />;
|
} else <span className="loader" />;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user