Add Session Page

This commit is contained in:
zkldi
2021-11-27 20:28:15 +00:00
parent 489e82b7e8
commit a5cc53e337
11 changed files with 987 additions and 492 deletions
@@ -97,12 +97,13 @@ export default function SessionsPage({
<GenericSessionTable
indexCol={sessionSet === "best"}
dataset={data!}
reqUser={reqUser}
game={game}
playtype={playtype}
/>
</LoadingWrapper>
) : (
<SearchSessionsTable {...{ game, playtype, baseUrl, search }} />
<SearchSessionsTable {...{ game, playtype, reqUser, baseUrl, search }} />
)}
</div>
</div>
@@ -113,8 +114,9 @@ function SearchSessionsTable({
search,
game,
playtype,
reqUser,
baseUrl,
}: { search: string; baseUrl: string } & GamePT) {
}: { search: string; baseUrl: string; reqUser: PublicUserDocument } & GamePT) {
const { isLoading, error, data } = useQuery<SessionDataset, UnsuccessfulAPIResponse>(
`${baseUrl}?search=${search}`,
async () => {
@@ -135,7 +137,12 @@ function SearchSessionsTable({
return (
<LoadingWrapper {...{ isLoading, error, dataset: data }}>
<GenericSessionTable dataset={data!} game={game} playtype={playtype} />
<GenericSessionTable
reqUser={reqUser}
dataset={data!}
game={game}
playtype={playtype}
/>
</LoadingWrapper>
);
}
@@ -0,0 +1,298 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import SessionOverview from "components/sessions/SessionOverview";
import SessionRaiseBreakdown from "components/sessions/SessionRaiseBreakdown";
import ScoreTable from "components/tables/scores/ScoreTable";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import FormInput from "components/util/FormInput";
import Icon from "components/util/Icon";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import SelectButton from "components/util/SelectButton";
import { UserContext } from "context/UserContext";
import React, { useContext, useMemo, useState } from "react";
import { Badge, Button, Col, Form, Modal, Row } from "react-bootstrap";
import { Redirect, useParams } from "react-router-dom";
import { GetGameConfig, PublicUserDocument, SessionDocument } from "tachi-common";
import { SessionReturns } from "types/api-returns";
import { GamePT, SetState } from "types/react";
import { APIFetchV1 } from "util/api";
import { CreateChartMap, CreateScoreIDMap, CreateSongMap } from "util/data";
import { DelayedPageReload } from "util/misc";
type Props = { reqUser: PublicUserDocument } & GamePT;
export default function SpecificSessionPage({ reqUser, game, playtype }: Props) {
const { sessionID } = useParams<{ sessionID: string }>();
const { data, isLoading, error } = useApiQuery<SessionReturns>(`/sessions/${sessionID}`);
if (error) {
return <ApiError error={error} />;
}
if (isLoading || !data) {
return <Loading />;
}
if (
data.user.id !== reqUser.id ||
game !== data.session.game ||
playtype !== data.session.playtype
) {
return (
<Redirect
to={`/dashboard/users/${data.user.username}/games/${data.session.game}/${data.session.playtype}/sessions/${sessionID}`}
/>
);
}
return <SessionPage {...{ data, game, playtype, reqUser }} />;
}
function SessionPage({ data, game, playtype }: Props & { data: SessionReturns }) {
const { session, user, charts, scores, songs } = data;
const { user: loggedInUser } = useContext(UserContext);
useSetSubheader(
[
"Users",
user.username,
"Games",
GetGameConfig(game).name,
playtype,
"Sessions",
session.name,
],
[session.name, game, playtype, user],
`${user.username}: ${session.name}`
);
const [view, setView] = useState<"raises" | "overview" | "scores">("overview");
const songMap = CreateSongMap(songs);
const chartMap = CreateChartMap(charts);
const scoreMap = CreateScoreIDMap(scores);
const scoreDataset = useMemo(() => {
const d = [];
for (const scoreInfo of session.scoreInfo) {
const score = scoreMap.get(scoreInfo.scoreID);
if (!score) {
console.error(`No score for scoreID ${scoreInfo.scoreID}, but one was in session?`);
continue;
}
const chart = chartMap.get(score.chartID);
const song = songMap.get(score.songID);
if (!chart || !song) {
console.error(`No chart for ${score.chartID} (${score.songID})?`);
continue;
}
d.push({
...score,
__related: {
chart,
song,
index: 0,
user,
},
});
}
return d;
}, [data]);
const [highlight, setHighlight] = useState(session.highlight);
const [showEditModal, setShowEditModal] = useState(false);
return (
<Row className="justify-content-center">
<Col xs={12} className="text-center">
<h1>{session.name}</h1>
<h4 className="text-muted mb-4">{session.desc}</h4>
<Badge variant="secondary">
<span
style={{
lineHeight: "15px",
verticalAlign: "middle",
}}
>
<Icon type="eye" style={{ verticalAlign: "middle" }} /> Views:{" "}
{session.views}
</span>
</Badge>
{session.highlight && (
<Badge
style={{
lineHeight: "15px",
}}
variant="warning"
className="ml-2"
>
Highlight!
</Badge>
)}
<Divider className="mt-8 mb-4" />
{user.id === loggedInUser?.id && (
<>
<Button
variant="outline-info"
onClick={() => setShowEditModal(true)}
className="mr-4"
>
Edit Session
</Button>
{highlight ? (
<Button
variant="outline-danger"
onClick={async () => {
setHighlight(false);
session.highlight = false;
await APIFetchV1(
`/sessions/${session.sessionID}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
highlight: false,
}),
},
true,
true
);
}}
>
Un-Highlight Session
</Button>
) : (
<Button
variant="outline-warning"
onClick={async () => {
setHighlight(true);
session.highlight = true;
await APIFetchV1(
`/sessions/${session.sessionID}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
highlight: true,
}),
},
true,
true
);
}}
>
Highlight Session
</Button>
)}
<Divider />
</>
)}
<div className="btn-group">
<SelectButton value={view} setValue={setView} id="raises">
<Icon type="receipt" />
Raises
</SelectButton>
<SelectButton value={view} setValue={setView} id="overview">
<Icon type="chart-area" />
Overview
</SelectButton>
<SelectButton value={view} setValue={setView} id="scores">
<Icon type="table" />
All Scores
</SelectButton>
</div>
<Divider />
</Col>
{view === "raises" ? (
<SessionRaiseBreakdown sessionData={data} />
) : view === "overview" ? (
<SessionOverview scoreDataset={scoreDataset} sessionData={data} />
) : (
<Col xs={12}>
<ScoreTable
dataset={scoreDataset}
game={session.game}
playtype={session.playtype}
/>
</Col>
)}
{showEditModal && (
<EditModal session={session} show={showEditModal} setShow={setShowEditModal} />
)}
</Row>
);
}
function EditModal({
session,
show,
setShow,
}: {
session: SessionDocument;
show: boolean;
setShow: SetState<boolean>;
}) {
const [name, setName] = useState(session.name);
const [desc, setDesc] = useState(session.desc ?? "No Description...");
return (
<Modal show={show} onHide={() => setShow(false)}>
<Modal.Header closeButton>
<Modal.Title>Edit Comment</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form
onSubmit={async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const r = await APIFetchV1(
`/sessions/${session.sessionID}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
desc,
}),
},
true,
true
);
if (r.success) {
DelayedPageReload();
}
}}
>
<FormInput fieldName="Session Name" value={name} setValue={setName} />
<Divider />
<FormInput
fieldName="Session Description"
value={desc ?? ""}
setValue={setDesc as SetState<string>}
/>
<Divider />
<Button type="submit">Submit</Button>
</Form>
</Modal.Body>
</Modal>
);
}
+4
View File
@@ -4,6 +4,7 @@ import FoldersMainPage from "app/pages/dashboard/users/games/_game/_playtype/fol
import LeaderboardsPage from "app/pages/dashboard/users/games/_game/_playtype/LeaderboardsPage";
import OverviewPage from "app/pages/dashboard/users/games/_game/_playtype/OverviewPage";
import SessionsPage from "app/pages/dashboard/users/games/_game/_playtype/SessionsPage";
import SpecificSessionPage from "app/pages/dashboard/users/games/_game/_playtype/SpecificSessionPage";
import UGPTSettingsPage from "app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage";
import UserGamesPage from "app/pages/dashboard/users/UserGamesPage";
import UserIntegrationsPage from "app/pages/dashboard/users/UserIntegrationsPage";
@@ -247,6 +248,9 @@ function UserGamePlaytypeRoutes({ reqUser, game }: { reqUser: PublicUserDocument
<Route exact path="/dashboard/users/:userID/games/:game/:playtype/sessions">
<SessionsPage reqUser={reqUser} game={game} playtype={playtype} />
</Route>
<Route path="/dashboard/users/:userID/games/:game/:playtype/sessions/:sessionID">
<SpecificSessionPage reqUser={reqUser} game={game} playtype={playtype} />
</Route>
<Route exact path="/dashboard/users/:userID/games/:game/:playtype/achievables">
<AchievablesPage reqUser={reqUser} game={game} playtype={playtype} />
</Route>
+1 -1
View File
@@ -179,7 +179,7 @@ function SessionTab({ data }: { data: Data }) {
<tr>
<td>
<Link
to={`/dashboard/games/${r.session.game}/${r.session.playtype}/sessions/${r.session.sessionID}`}
to={`/dashboard/users/${r.session.userID}/games/${r.session.game}/${r.session.playtype}/sessions/${r.session.sessionID}`}
className="gentle-link"
>
{r.session.name}
@@ -0,0 +1,165 @@
import { ResponsiveLine } from "@nivo/line";
import ChartTooltip from "components/charts/ChartTooltip";
import Card from "components/layout/page/Card";
import ScoreTable from "components/tables/scores/ScoreTable";
import DebugContent from "components/util/DebugContent";
import Divider from "components/util/Divider";
import { UserContext } from "context/UserContext";
import { ColourConfig } from "lib/config";
import React, { useContext, useMemo } from "react";
import { Col } from "react-bootstrap";
import { ChartDocument, GetGamePTConfig, ScoreDocument, SongDocument } from "tachi-common";
import { SessionReturns } from "types/api-returns";
import { GamePT } from "types/react";
import { ScoreDataset } from "types/tables";
import { TACHI_CHART_THEME } from "util/constants/chart-theme";
import { UppercaseFirst } from "util/misc";
import { NumericSOV } from "util/sorts";
import { FormatDuration } from "util/time";
export default function SessionOverview({
sessionData,
scoreDataset,
}: {
sessionData: SessionReturns;
scoreDataset: ScoreDataset;
}) {
const { scores, session } = sessionData;
const { user } = useContext(UserContext);
// const performanceDataset = useMemo(() => {
// const d = [];
// for (const scoreInfo of session.scoreInfo) {
// const score = scoreMap.get(scoreInfo.scoreID);
// if (!score) {
// console.error(`Score ${scoreInfo.scoreID} doesn't exist?`);
// continue;
// }
// const chart = chartMap.get(score.chartID);
// const song = songMap.get(score.songID);
// if (!chart || !song) {
// console.error(`Chart ${score.chartID} (${score.songID}) has no chart?`);
// continue;
// }
// d.push({
// ...score,
// __related: {
// song,
// chart,
// },
// });
// }
// return d;
// }, [sessionData]);
return (
<>
<StatThing name="Scores" value={session.scoreInfo.length} />
<StatThing
name="Duration"
value={FormatDuration(session.timeEnded - session.timeStarted)}
/>
<StatThing md12 name="Highlights" value={scores.filter(e => e.highlight).length} />
<Col xs={12}>
<Divider />
<Card header="Highlights">
<ScoreTable
game={session.game}
playtype={session.playtype}
dataset={scoreDataset.filter(e => e.highlight)}
/>
</Card>
</Col>
</>
);
}
function StatThing({
value,
name,
md12,
}: {
value: string | number;
name: string;
md12?: boolean;
}) {
return (
<Col xs={12} md={md12 ? 12 : 6} lg={4}>
<div className="card">
<div className="card-body">
<div className="display-4">{value}</div>
<div style={{ fontSize: "1.2rem" }}>{name}</div>
</div>
</div>
</Col>
);
}
type PerformanceData = ScoreDocument & {
__related: {
chart: ChartDocument;
song: SongDocument;
};
};
function PerformanceComponent({
dataset,
timeStart,
game,
playtype,
}: {
dataset: PerformanceData[];
timeStart: number;
} & GamePT) {
const gptConfig = GetGamePTConfig(game, playtype);
const data = useMemo(() => {
const data = [];
for (const alg of gptConfig.scoreRatingAlgs) {
data.push({
id: UppercaseFirst(alg),
data: dataset
.map(e => ({
x: e.timeAchieved!,
y: (e.calculatedData[alg] ?? 0) < 0 ? null : e.calculatedData[alg] ?? 0,
}))
.sort(NumericSOV(e => e.x, true)),
});
}
return data;
}, [dataset]);
return (
<Card header="Session Graph" style={{ width: "100%" }}>
<div style={{ width: "100%", height: "400px" }}>
<ResponsiveLine
data={data}
margin={{ top: 30, bottom: 50, left: 40, right: 40 }}
xScale={{ type: "time", format: "%Q" }}
axisBottom={{
format: (x: Date) => FormatDuration(timeStart - x.valueOf()),
}}
gridXValues={3}
motionConfig="stiff"
crosshairType="x"
yScale={{ type: "linear", min: "auto", max: "auto" }}
colors={[ColourConfig.primary]}
useMesh={true}
theme={TACHI_CHART_THEME}
tooltip={d => (
<ChartTooltip point={d.point} renderFn={r => <DebugContent data={r} />} />
)}
// legends={[]}
enableArea
/>
</div>
</Card>
);
}
@@ -0,0 +1,476 @@
import DifficultyCell from "components/tables/cells/DifficultyCell";
import LampCell from "components/tables/cells/LampCell";
import ScoreCell from "components/tables/cells/ScoreCell";
import TitleCell from "components/tables/cells/TitleCell";
import MiniTable from "components/tables/components/MiniTable";
import Loading from "components/util/Loading";
import SelectButton from "components/util/SelectButton";
import deepmerge from "deepmerge";
import { nanoid } from "nanoid";
import React, { useState, useEffect, useMemo } from "react";
import { useQuery } from "react-query";
import Divider from "components/util/Divider";
import Icon from "components/util/Icon";
import {
FolderDocument,
TableDocument,
Lamps,
IDStrings,
ScoreDocument,
SessionScoreInfo,
Grades,
GetGamePTConfig,
GamePTConfig,
integer,
SongDocument,
Game,
ChartDocument,
} from "tachi-common";
import { SessionReturns } from "types/api-returns";
import { APIFetchV1 } from "util/api";
import { CreateSongMap, CreateChartMap, CreateScoreIDMap } from "util/data";
import { PartialArrayRecordAssign } from "util/misc";
import { ChangeOpacity } from "util/color-opacity";
export default function SessionRaiseBreakdown({ sessionData }: { sessionData: SessionReturns }) {
const game = sessionData.session.game;
const playtype = sessionData.session.playtype;
// todo infer default table
// const gptConfig = GetGamePTConfig(game, playtype);
const [chartIDs, setChartIDs] = useState<string[] | null>([]);
const [tableID, setTableID] = useState<string | null>(null);
const [tableFolders, setTableFolders] = useState<FolderDocument[]>([]);
const [folder, setFolder] = useState<FolderDocument | null>(null);
const [filter, setFilter] = useState<"folder" | "all" | "highlighted">("all");
useEffect(() => {
if (!tableID) {
return;
}
APIFetchV1(`/games/${game}/${playtype}/tables/${tableID}`).then(r => {
if (!r.success) {
throw new Error(r.description);
}
// @ts-expect-error todo
setTableFolders(r.body.folders);
// @ts-expect-error todo
setFolder(r.body.folders[r.body.folders.length - 1]);
});
}, [tableID]);
useEffect(() => {
if (filter !== "folder" || !folder) {
setChartIDs(null);
return;
}
APIFetchV1(`/games/${game}/${playtype}/folders/${folder.folderID}`).then(r => {
if (!r.success) {
throw new Error(r.description);
}
// @ts-expect-error todo
setChartIDs(r.body.charts.map(e => e.chartID));
});
}, [folder, tableID, filter]);
const { isLoading, error, data } = useQuery(`/games/${game}/${playtype}/tables`, async () => {
const res = await APIFetchV1<TableDocument[]>(`/games/${game}/${playtype}/tables`);
if (!res.success) {
throw new Error(res.description);
}
return res.body;
});
const [view, setView] = useState<"lamps" | "both" | "grades">("both");
useEffect(() => {
if (tableID === null && (data?.length ?? 0) > 0) {
setTableID(data![0].tableID);
}
}, [data]);
if (error) {
return <>{(error as Error).message}</>;
}
if (isLoading || !data) {
return <Loading />;
}
return (
<>
<div className="col-12">
<div className="row">
<div className="col-6 col-lg-6 offset-lg-3">
<div className="d-flex justify-content-center">
<div className="btn-group">
<SelectButton value={view} setValue={setView} id="lamps">
<Icon type="lightbulb" />
New Lamps
</SelectButton>
<SelectButton value={view} setValue={setView} id="both">
<Icon type="bolt" />
Both
</SelectButton>
<SelectButton value={view} setValue={setView} id="grades">
<Icon type="sort-alpha-up" />
New Grades
</SelectButton>
</div>
</div>
</div>
<div className="col-6 col-lg-3">
<select
className="form-control ml-auto"
value={filter}
onChange={e => setFilter(e.target.value as any)}
>
<option value="all">No Filter</option>
<option value="folder">Folder Filters</option>
<option value="highlighted">Highlights Only</option>
</select>
</div>
{filter === "folder" && (
<>
<div className="col-12">
<Divider className="mt-4 mb-4" />
</div>
<div className="col-6">
<select
className="form-control"
onChange={e => setTableID(e.target.value)}
>
{data.map(e => (
<option key={e.tableID} value={e.tableID}>
{e.title}
</option>
))}
</select>
</div>
<div className="col-6">
<select
className="form-control"
value={folder?.folderID}
onChange={e =>
setFolder(
tableFolders.find(f => f.folderID === e.target.value)!
)
}
>
{tableFolders.map(e => (
<option key={e.folderID} value={e.folderID}>
{e.title}
</option>
))}
</select>
</div>
</>
)}
</div>
<Divider className="mt-4 mb-4" />
</div>
<SessionScoreStatBreakdown {...{ sessionData, chartIDs, filter, view }} />
</>
);
}
function SessionScoreStatBreakdown({
sessionData,
chartIDs,
filter,
view,
}: {
sessionData: SessionReturns;
chartIDs: string[] | null;
filter: "folder" | "all" | "highlighted";
view: "lamps" | "both" | "grades";
}) {
const songMap = CreateSongMap(sessionData.songs);
const chartMap = CreateChartMap(sessionData.charts);
const scoreMap = CreateScoreIDMap(sessionData.scores);
const [newLamps, newGrades] = useMemo(() => {
const newLamps: Partial<Record<
Lamps[IDStrings],
{ score: ScoreDocument; scoreInfo: SessionScoreInfo }[]
>> = {};
const newGrades: Partial<Record<
Grades[IDStrings],
{ score: ScoreDocument; scoreInfo: SessionScoreInfo }[]
>> = {};
for (const scoreInfo of sessionData.session.scoreInfo) {
const score = scoreMap.get(scoreInfo.scoreID);
if (!score) {
console.error(
`Session score info contains scoreID ${scoreInfo.scoreID}, but no score exists?`
);
continue;
}
if (filter === "folder" && chartIDs && !chartIDs.includes(score.chartID)) {
continue;
} else if (filter === "highlighted" && !score.highlight) {
continue;
}
if (scoreInfo.isNewScore) {
PartialArrayRecordAssign(newLamps, score.scoreData.lamp, { score, scoreInfo });
PartialArrayRecordAssign(newGrades, score.scoreData.grade, { score, scoreInfo });
} else {
if (scoreInfo.lampDelta > 0) {
PartialArrayRecordAssign(newLamps, score.scoreData.lamp, { score, scoreInfo });
}
if (scoreInfo.gradeDelta > 0) {
PartialArrayRecordAssign(newGrades, score.scoreData.grade, {
score,
scoreInfo,
});
}
}
}
return [newLamps, newGrades];
}, [chartIDs, filter, view]);
const gptConfig = GetGamePTConfig(sessionData.session.game, sessionData.session.playtype);
return (
<>
{view === "both" ? (
<>
<div className="col-12 col-lg-6">
<MiniTable
headers={["Lamp", "New Lamps"]}
colSpan={[1, 2]}
className="table-sm"
>
<ElementStatTable
chartMap={chartMap}
songMap={songMap}
counts={newLamps}
game={sessionData.session.game}
type="lamp"
gptConfig={gptConfig}
/>
</MiniTable>
</div>
<div className="col-12 col-lg-6">
<MiniTable
headers={["Grade", "New Grades"]}
colSpan={[1, 2]}
className="table-sm"
>
<ElementStatTable
chartMap={chartMap}
songMap={songMap}
counts={newGrades}
game={sessionData.session.game}
type="grade"
gptConfig={gptConfig}
/>
</MiniTable>
</div>
</>
) : view === "grades" ? (
<div className="col-12">
<MiniTable headers={["Grade", "New Grades"]} colSpan={[1, 100]}>
<ElementStatTable
fullSize
chartMap={chartMap}
songMap={songMap}
counts={newGrades}
game={sessionData.session.game}
type="grade"
gptConfig={gptConfig}
/>
</MiniTable>
</div>
) : (
<div className="col-12">
<MiniTable headers={["Lamps", "New Lamps"]} colSpan={[1, 100]}>
<ElementStatTable
fullSize
chartMap={chartMap}
songMap={songMap}
counts={newLamps}
game={sessionData.session.game}
type="lamp"
gptConfig={gptConfig}
/>
</MiniTable>
</div>
)}
</>
);
}
function ElementStatTable({
type,
counts,
gptConfig,
songMap,
chartMap,
game,
fullSize = false,
}: {
type: "lamp" | "grade";
counts: Record<string, { score: ScoreDocument; scoreInfo: SessionScoreInfo }[]>;
gptConfig: GamePTConfig;
songMap: Map<integer, SongDocument<Game>>;
chartMap: Map<string, ChartDocument<IDStrings>>;
game: Game;
fullSize?: boolean;
}) {
const tableContents = useMemo(() => {
// relements.. haha
const relevantElements =
type === "lamp"
? gptConfig.lamps.slice(gptConfig.lamps.indexOf(gptConfig.clearLamp) - 1).reverse()
: gptConfig.grades
.slice(gptConfig.grades.indexOf(gptConfig.clearGrade) - 1)
.reverse();
const colours = type === "lamp" ? gptConfig.lampColours : gptConfig.gradeColours;
const tableContents = [];
for (const element of relevantElements) {
if (!counts[element] || !counts[element].length) {
continue;
}
const firstData = counts[element][0];
tableContents.push(
<tr key={element}>
<td
style={{
// @ts-expect-error this is a hack due to the funky type of colours and element.
backgroundColor: ChangeOpacity(colours[element], 0.1),
}}
rowSpan={counts[element]!.length}
>
{element}
</td>
<BreakdownChartContents
{...firstData}
{...{ chartMap, songMap, fullSize, game, gptConfig, type }}
/>
</tr>
);
for (const data of counts[element]!.slice(1)) {
tableContents.push(
<tr key={nanoid()}>
<BreakdownChartContents
{...data}
{...{ chartMap, songMap, fullSize, game, gptConfig, type }}
/>
</tr>
);
}
}
return tableContents;
}, [type, counts, fullSize, game]);
if (tableContents.length === 0) {
return (
<tr>
<td colSpan={3}>Nothing...</td>
</tr>
);
}
return <>{tableContents}</>;
}
function BreakdownChartContents({
score,
scoreInfo,
game,
songMap,
chartMap,
fullSize,
gptConfig,
type,
}: {
score: ScoreDocument;
scoreInfo: SessionScoreInfo;
fullSize: boolean;
game: Game;
songMap: Map<integer, SongDocument>;
chartMap: Map<string, ChartDocument>;
gptConfig: GamePTConfig;
type: "lamp" | "grade";
}) {
const chart = chartMap.get(score.chartID)!;
const song = songMap.get(score.songID)!;
if (!chart || !song) {
console.error(`No chart for ${score.chartID}/${score.songID}???`);
return null;
}
if (fullSize) {
let preScoreCell = <td>No Play</td>;
if (!scoreInfo.isNewScore) {
const oldGradeIndex = score.scoreData.gradeIndex - scoreInfo.gradeDelta;
const oldLampIndex = score.scoreData.lampIndex - scoreInfo.lampDelta;
const mockScore = deepmerge(score, {
scoreData: {
score: score.scoreData.score - scoreInfo.scoreDelta,
percent: score.scoreData.percent - scoreInfo.percentDelta,
grade: gptConfig.grades[oldGradeIndex],
gradeIndex: oldGradeIndex,
lamp: gptConfig.lamps[oldLampIndex],
lampIndex: oldLampIndex,
},
}) as ScoreDocument;
if (type === "grade") {
preScoreCell = <ScoreCell score={mockScore} />;
} else {
preScoreCell = <LampCell score={mockScore} />;
}
}
console.log(game);
if (score) {
return (
<>
<TitleCell chart={chart} game={game} song={song} />
<DifficultyCell chart={chart} game={game} />
{preScoreCell}
<td>⟶</td>
{type === "grade" ? (
<ScoreCell {...{ score, game, playtype: score.playtype }} />
) : (
<LampCell score={score} />
)}
</>
);
}
}
return (
<>
<TitleCell noArtist chart={chart} game={game} song={song} />
<DifficultyCell alwaysShort chart={chart} game={game} />
</>
);
}
@@ -1,38 +1,13 @@
import SessionRaiseBreakdown from "components/sessions/SessionRaiseBreakdown";
import DebugContent from "components/util/DebugContent";
import Divider from "components/util/Divider";
import HasDevModeOn from "components/util/HasDevModeOn";
import Icon from "components/util/Icon";
import Loading from "components/util/Loading";
import SelectButton from "components/util/SelectButton";
import deepmerge from "deepmerge";
import { nanoid } from "nanoid";
import React, { useEffect, useMemo, useState } from "react";
import React, { useState } from "react";
import { useQuery } from "react-query";
import {
ChartDocument,
FolderDocument,
Game,
GamePTConfig,
GetGamePTConfig,
Grades,
IDStrings,
integer,
Lamps,
ScoreDocument,
SessionScoreInfo,
SongDocument,
TableDocument,
} from "tachi-common";
import { SpecificSessionReturns } from "types/api-returns";
import { SessionReturns } from "types/api-returns";
import { APIFetchV1 } from "util/api";
import { ChangeOpacity } from "util/color-opacity";
import { CreateChartMap, CreateScoreIDMap, CreateSongMap } from "util/data";
import { PartialArrayRecordAssign } from "util/misc";
import DifficultyCell from "../cells/DifficultyCell";
import LampCell from "../cells/LampCell";
import ScoreCell from "../cells/ScoreCell";
import TitleCell from "../cells/TitleCell";
import MiniTable from "../components/MiniTable";
import { SessionDataset } from "../sessions/GenericSessionTable";
import DropdownStructure from "./components/DropdownStructure";
@@ -40,7 +15,7 @@ export default function GenericSessionDropdown({ data }: { data: SessionDataset[
const { isLoading, error, data: sessionData } = useQuery(
`/sessions/${data.sessionID}`,
async () => {
const res = await APIFetchV1<SpecificSessionReturns>(`/sessions/${data.sessionID}`);
const res = await APIFetchV1<SessionReturns>(`/sessions/${data.sessionID}`);
if (!res.success) {
throw new Error(res.description);
@@ -89,448 +64,3 @@ export default function GenericSessionDropdown({ data }: { data: SessionDataset[
</DropdownStructure>
);
}
function SessionRaiseBreakdown({ sessionData }: { sessionData: SpecificSessionReturns }) {
const game = sessionData.session.game;
const playtype = sessionData.session.playtype;
// todo infer default table
// const gptConfig = GetGamePTConfig(game, playtype);
const [chartIDs, setChartIDs] = useState<string[] | null>([]);
const [tableID, setTableID] = useState<string | null>(null);
const [tableFolders, setTableFolders] = useState<FolderDocument[]>([]);
const [folder, setFolder] = useState<FolderDocument | null>(null);
const [filter, setFilter] = useState<"folder" | "all" | "highlighted">("all");
useEffect(() => {
if (!tableID) {
return;
}
APIFetchV1(`/games/${game}/${playtype}/tables/${tableID}`).then(r => {
if (!r.success) {
throw new Error(r.description);
}
// @ts-expect-error todo
setTableFolders(r.body.folders);
// @ts-expect-error todo
setFolder(r.body.folders[r.body.folders.length - 1]);
});
}, [tableID]);
useEffect(() => {
if (filter !== "folder" || !folder) {
setChartIDs(null);
return;
}
APIFetchV1(`/games/${game}/${playtype}/folders/${folder.folderID}`).then(r => {
if (!r.success) {
throw new Error(r.description);
}
// @ts-expect-error todo
setChartIDs(r.body.charts.map(e => e.chartID));
});
}, [folder, tableID, filter]);
const { isLoading, error, data } = useQuery(`/games/${game}/${playtype}/tables`, async () => {
const res = await APIFetchV1<TableDocument[]>(`/games/${game}/${playtype}/tables`);
if (!res.success) {
throw new Error(res.description);
}
return res.body;
});
useEffect(() => {
if (tableID === null && (data?.length ?? 0) > 0) {
setTableID(data![0].tableID);
}
}, [data]);
if (error) {
return <>{(error as Error).message}</>;
}
if (isLoading || !data) {
return <Loading />;
}
return (
<>
<div className="col-12">
<div className="row">
<div className="col-12">
<div className="btn-group">
<SelectButton value={filter} setValue={setFilter} id="folder">
<Icon type="folder-open" />
Folder Filter
</SelectButton>
<SelectButton value={filter} setValue={setFilter} id="all">
<Icon type="plus" />
No Filter
</SelectButton>
<SelectButton value={filter} setValue={setFilter} id="highlighted">
<Icon type="star" />
Highlights Only
</SelectButton>
</div>
</div>
{filter === "folder" && (
<>
<div className="col-12">
<Divider className="mt-4 mb-4" />
</div>
<div className="col-6">
<select
className="form-control"
onChange={e => setTableID(e.target.value)}
>
{data.map(e => (
<option key={e.tableID} value={e.tableID}>
{e.title}
</option>
))}
</select>
</div>
<div className="col-6">
<select
className="form-control"
value={folder?.folderID}
onChange={e =>
setFolder(
tableFolders.find(f => f.folderID === e.target.value)!
)
}
>
{tableFolders.map(e => (
<option key={e.folderID} value={e.folderID}>
{e.title}
</option>
))}
</select>
</div>
</>
)}
</div>
<Divider className="mt-4 mb-4" />
</div>
<SessionScoreStatBreakdown {...{ sessionData, chartIDs, filter }} />
</>
);
}
function SessionScoreStatBreakdown({
sessionData,
chartIDs,
filter,
}: {
sessionData: SpecificSessionReturns;
chartIDs: string[] | null;
filter: "folder" | "all" | "highlighted";
}) {
const [view, setView] = useState<"lamps" | "both" | "grades">("both");
const songMap = CreateSongMap(sessionData.songs);
const chartMap = CreateChartMap(sessionData.charts);
const scoreMap = CreateScoreIDMap(sessionData.scores);
const [newLamps, newGrades] = useMemo(() => {
const newLamps: Partial<Record<
Lamps[IDStrings],
{ score: ScoreDocument; scoreInfo: SessionScoreInfo }[]
>> = {};
const newGrades: Partial<Record<
Grades[IDStrings],
{ score: ScoreDocument; scoreInfo: SessionScoreInfo }[]
>> = {};
for (const scoreInfo of sessionData.session.scoreInfo) {
const score = scoreMap.get(scoreInfo.scoreID);
if (!score) {
console.error(
`Session score info contains scoreID ${scoreInfo.scoreID}, but no score exists?`
);
continue;
}
if (filter === "folder" && chartIDs && !chartIDs.includes(score.chartID)) {
continue;
} else if (filter === "highlighted" && !score.highlight) {
continue;
}
if (scoreInfo.isNewScore) {
PartialArrayRecordAssign(newLamps, score.scoreData.lamp, { score, scoreInfo });
PartialArrayRecordAssign(newGrades, score.scoreData.grade, { score, scoreInfo });
} else {
if (scoreInfo.lampDelta > 0) {
PartialArrayRecordAssign(newLamps, score.scoreData.lamp, { score, scoreInfo });
}
if (scoreInfo.gradeDelta > 0) {
PartialArrayRecordAssign(newGrades, score.scoreData.grade, {
score,
scoreInfo,
});
}
}
}
return [newLamps, newGrades];
}, [chartIDs, filter, view]);
const gptConfig = GetGamePTConfig(sessionData.session.game, sessionData.session.playtype);
return (
<>
<div className="col-12 d-none d-lg-block">
<div className="btn-group">
<SelectButton value={view} setValue={setView} id="lamps">
<Icon type="lightbulb" />
New Lamps
</SelectButton>
<SelectButton value={view} setValue={setView} id="both">
<Icon type="bolt" />
Both
</SelectButton>
<SelectButton value={view} setValue={setView} id="grades">
<Icon type="sort-alpha-up" />
New Grades
</SelectButton>
</div>
<Divider className="mt-4 mb-4" />
</div>
{view === "both" ? (
<>
<div className="col-12 col-lg-6">
<MiniTable
headers={["Lamp", "New Lamps"]}
colSpan={[1, 2]}
className="table-sm"
>
<ElementStatTable
chartMap={chartMap}
songMap={songMap}
counts={newLamps}
game={sessionData.session.game}
type="lamp"
gptConfig={gptConfig}
/>
</MiniTable>
</div>
<div className="col-12 col-lg-6">
<MiniTable
headers={["Grade", "New Grades"]}
colSpan={[1, 2]}
className="table-sm"
>
<ElementStatTable
chartMap={chartMap}
songMap={songMap}
counts={newGrades}
game={sessionData.session.game}
type="grade"
gptConfig={gptConfig}
/>
</MiniTable>
</div>
</>
) : view === "grades" ? (
<div className="col-12">
<MiniTable headers={["Grade", "New Grades"]} colSpan={[1, 100]}>
<ElementStatTable
fullSize
chartMap={chartMap}
songMap={songMap}
counts={newGrades}
game={sessionData.session.game}
type="grade"
gptConfig={gptConfig}
/>
</MiniTable>
</div>
) : (
<div className="col-12">
<MiniTable headers={["Lamps", "New Lamps"]} colSpan={[1, 100]}>
<ElementStatTable
fullSize
chartMap={chartMap}
songMap={songMap}
counts={newLamps}
game={sessionData.session.game}
type="lamp"
gptConfig={gptConfig}
/>
</MiniTable>
</div>
)}
</>
);
}
function ElementStatTable({
type,
counts,
gptConfig,
songMap,
chartMap,
game,
fullSize = false,
}: {
type: "lamp" | "grade";
counts: Record<string, { score: ScoreDocument; scoreInfo: SessionScoreInfo }[]>;
gptConfig: GamePTConfig;
songMap: Map<integer, SongDocument<Game>>;
chartMap: Map<string, ChartDocument<IDStrings>>;
game: Game;
fullSize?: boolean;
}) {
const tableContents = useMemo(() => {
// relements.. haha
const relevantElements =
type === "lamp"
? gptConfig.lamps.slice(gptConfig.lamps.indexOf(gptConfig.clearLamp) - 1).reverse()
: gptConfig.grades
.slice(gptConfig.grades.indexOf(gptConfig.clearGrade) - 1)
.reverse();
const colours = type === "lamp" ? gptConfig.lampColours : gptConfig.gradeColours;
const tableContents = [];
for (const element of relevantElements) {
if (!counts[element] || !counts[element].length) {
continue;
}
const firstData = counts[element][0];
tableContents.push(
<tr key={element}>
<td
style={{
// @ts-expect-error this is a hack due to the funky type of colours and element.
backgroundColor: ChangeOpacity(colours[element], 0.1),
}}
rowSpan={counts[element]!.length}
>
{element}
</td>
<BreakdownChartContents
{...firstData}
{...{ chartMap, songMap, fullSize, game, gptConfig, type }}
/>
</tr>
);
for (const data of counts[element]!.slice(1)) {
tableContents.push(
<tr key={nanoid()}>
<BreakdownChartContents
{...data}
{...{ chartMap, songMap, fullSize, game, gptConfig, type }}
/>
</tr>
);
}
}
return tableContents;
}, [type, counts, fullSize, game]);
if (tableContents.length === 0) {
return (
<tr>
<td colSpan={3}>Nothing...</td>
</tr>
);
}
return <>{tableContents}</>;
}
function BreakdownChartContents({
score,
scoreInfo,
game,
songMap,
chartMap,
fullSize,
gptConfig,
type,
}: {
score: ScoreDocument;
scoreInfo: SessionScoreInfo;
fullSize: boolean;
game: Game;
songMap: Map<integer, SongDocument>;
chartMap: Map<string, ChartDocument>;
gptConfig: GamePTConfig;
type: "lamp" | "grade";
}) {
const chart = chartMap.get(score.chartID)!;
const song = songMap.get(score.songID)!;
if (!chart || !song) {
console.error(`No chart for ${score.chartID}/${score.songID}???`);
return null;
}
if (fullSize) {
let preScoreCell = <td>No Play</td>;
if (!scoreInfo.isNewScore) {
const oldGradeIndex = score.scoreData.gradeIndex - scoreInfo.gradeDelta;
const oldLampIndex = score.scoreData.lampIndex - scoreInfo.lampDelta;
const mockScore = deepmerge(score, {
scoreData: {
score: score.scoreData.score - scoreInfo.scoreDelta,
percent: score.scoreData.percent - scoreInfo.percentDelta,
grade: gptConfig.grades[oldGradeIndex],
gradeIndex: oldGradeIndex,
lamp: gptConfig.lamps[oldLampIndex],
lampIndex: oldLampIndex,
},
}) as ScoreDocument;
if (type === "grade") {
preScoreCell = <ScoreCell score={mockScore} />;
} else {
preScoreCell = <LampCell score={mockScore} />;
}
}
console.log(game);
if (score) {
return (
<>
<TitleCell chart={chart} game={game} song={song} />
<DifficultyCell chart={chart} game={game} />
{preScoreCell}
<td>⟶</td>
{type === "grade" ? (
<ScoreCell {...{ score, game, playtype: score.playtype }} />
) : (
<LampCell score={score} />
)}
</>
);
}
}
return (
<>
<TitleCell noArtist chart={chart} game={game} song={song} />
<DifficultyCell alwaysShort chart={chart} game={game} />
</>
);
}
@@ -1,11 +1,13 @@
import { useSessionRatingAlg } from "components/util/useScoreRatingAlg";
import { nanoid } from "nanoid";
import React, { useState } from "react";
import { Link } from "react-router-dom";
import {
Game,
GetGamePTConfig,
IDStrings,
integer,
PublicUserDocument,
SessionCalculatedDataLookup,
SessionDocument,
} from "tachi-common";
@@ -25,11 +27,13 @@ export type SessionDataset = (SessionDocument & { __related: { index: integer }
export default function GenericSessionTable({
dataset,
indexCol = false,
reqUser,
game,
playtype,
}: {
dataset: SessionDataset;
indexCol?: boolean;
reqUser: PublicUserDocument;
game: Game;
playtype: Playtype;
}) {
@@ -84,7 +88,15 @@ export default function GenericSessionTable({
timestamp: x => x.timeStarted,
[alg]: x => x.calculatedData[alg] ?? 0,
}}
rowFunction={s => <Row data={s} key={s.sessionID} rating={alg} indexCol={indexCol} />}
rowFunction={s => (
<Row
reqUser={reqUser}
data={s}
key={s.sessionID}
rating={alg}
indexCol={indexCol}
/>
)}
/>
);
}
@@ -92,12 +104,14 @@ export default function GenericSessionTable({
function Row({
data,
rating,
reqUser,
indexCol = false,
}: // reqUser,
{
data: SessionDataset[0];
// reqUser: PublicUserDocument;
rating: SessionCalculatedDataLookup[IDStrings];
reqUser: PublicUserDocument;
indexCol?: boolean;
}) {
const [highlight, setHighlight] = useState(data.highlight);
@@ -106,13 +120,18 @@ function Row({
const sessionState = { highlight, desc, setHighlight, setDesc };
return (
<DropdownRow
<tr
className={highlight ? "highlighted-row" : ""}
dropdown={<GenericSessionDropdown data={data} />}
// dropdown={<GenericSessionDropdown data={data} />}
>
{indexCol && <IndexCell index={data.__related.index} />}
<td style={{ minWidth: "140px" }}>
{data.name}
<Link
to={`/dashboard/users/${reqUser.username}/games/${data.game}/${data.playtype}/sessions/${data.sessionID}`}
className="gentle-link"
>
{data.name}
</Link>
<br />
<small className="text-muted">{desc}</small>
</td>
@@ -130,6 +149,6 @@ function Row({
<br />
<small className="text-muted">{FormatTime(data.timeStarted)}</small>
</td>
</DropdownRow>
</tr>
);
}
+4 -1
View File
@@ -1,4 +1,4 @@
import React from "react";
import React, { CSSProperties } from "react";
export default function Icon({
type,
@@ -6,12 +6,14 @@ export default function Icon({
brand,
onClick,
colour,
style,
}: {
type: string;
noPad?: boolean;
brand?: boolean;
onClick?: () => void;
colour?: "info" | "primary" | "danger" | "warning";
style?: CSSProperties;
}) {
return (
<i
@@ -19,6 +21,7 @@ export default function Icon({
className={`fa${brand ? "b" : "s"} fa-${type} ${noPad ? "p-0" : ""} ${
colour ? `text-${colour}` : ""
}`}
style={style}
/>
);
}
-8
View File
@@ -82,14 +82,6 @@ export interface UGPTChartPBComposition<I extends IDStrings = IDStrings> {
pb: PBScoreDocument<I>;
}
export interface SpecificSessionReturns<I extends IDStrings = IDStrings> {
session: SessionDocument<I>;
scores: ScoreDocument<I>[];
songs: SongDocument<IDStringToGame[I]>[];
charts: ChartDocument<I>[];
user: PublicUserDocument;
}
export type UGSWithRankingData<I extends IDStrings = IDStrings> = UserGameStats<I> & {
__rankingData: Record<UGSRatingsLookup[I], { outOf: number; ranking: number }>;
};
+1
View File
@@ -90,6 +90,7 @@ export const loggedInSplashes = [
"Discovered Colours",
"Meeting People Is Easy",
"See ya later, innovator",
"bou bou bounce",
];
// Stuff that's bad for the kids, you know?