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 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:
|
||||
|
72
src/App.css
72
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;
|
||||
}
|
||||
}
|
||||
|
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,
|
||||
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<Session>({
|
||||
user: null,
|
||||
teams: null,
|
||||
setTeams: () => {},
|
||||
players: null,
|
||||
onLogout: () => {},
|
||||
});
|
||||
|
||||
@ -38,6 +40,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);
|
||||
|
||||
@ -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 = <Login onLogin={onLogin} />;
|
||||
} else
|
||||
content = (
|
||||
<sessionContext.Provider value={{ user, teams, setTeams, onLogout }}>
|
||||
<sessionContext.Provider
|
||||
value={{ user, teams, setTeams, players, onLogout }}
|
||||
>
|
||||
{children}
|
||||
</sessionContext.Provider>
|
||||
);
|
||||
|
@ -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) => (
|
||||
<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 +138,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 +226,7 @@ const TeamPanel = () => {
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
<Calendar playerId={player.id} />
|
||||
</div>
|
||||
);
|
||||
} else <span className="loader" />;
|
||||
|
Loading…
x
Reference in New Issue
Block a user