diff --git a/client/src/_assets/metronic-scss b/client/src/_assets/metronic-scss index f93aa62cf..d44b4be29 160000 --- a/client/src/_assets/metronic-scss +++ b/client/src/_assets/metronic-scss @@ -1 +1 @@ -Subproject commit f93aa62cfb31bb9f656443a5de6d798ce313b7e5 +Subproject commit d44b4be29500a7dfb668f6f5d9703c2f619d71f8 diff --git a/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx b/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx index ec74f30c6..3477cb8be 100644 --- a/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx +++ b/client/src/app/pages/dashboard/games/_game/_playtype/GPTChartPage.tsx @@ -29,7 +29,11 @@ import { PublicUserDocument, SongDocument, } from "tachi-common"; -import { ChartPBLeaderboardReturn, UGPTChartLeaderboardAdjacent } from "types/api-returns"; +import { + ChartPBLeaderboardReturn, + ChartRivalsReturn, + UGPTChartLeaderboardAdjacent, +} from "types/api-returns"; import { GamePT, SetState } from "types/react"; import { PBDataset } from "types/tables"; @@ -73,6 +77,7 @@ export default function GPTChartPage({ interface ChartPBData { leaderboard: ChartPBLeaderboardReturn; adjacent?: UGPTChartLeaderboardAdjacent; + rivals?: ChartRivalsReturn; playcount: integer; } @@ -87,7 +92,7 @@ function InternalGPTChartPage({ } & GamePT) { const { user } = useContext(UserContext); - const [mode, setMode] = useState<"leaderboard" | "adjacent">("leaderboard"); + const [mode, setMode] = useState<"leaderboard" | "adjacent" | "rivals">("leaderboard"); const { data, error } = useQuery( ["PBInfo", `${chart.chartID}`], @@ -113,15 +118,24 @@ function InternalGPTChartPage({ `/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}/leaderboard-adjacent` ); - if (!nRes.success) { - return { leaderboard: lRes.body, playcount: pRes.body.count }; - } + const rRes = await APIFetchV1( + `/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}/rivals` + ); - return { + const returnValue: ChartPBData = { leaderboard: lRes.body, - adjacent: nRes.body, playcount: pRes.body.count, }; + + if (nRes.success) { + returnValue.adjacent = nRes.body; + } + + if (rRes.success) { + returnValue.rivals = rRes.body; + } + + return returnValue; } return { leaderboard: lRes.body, playcount: pRes.body.count }; @@ -148,6 +162,12 @@ function InternalGPTChartPage({ } } + if (data.rivals) { + for (const user of data.rivals.rivals) { + userMap.set(user.id, user); + } + } + if (user) { // Add current user, since there's no guarantee they are returned from either API. userMap.set(user.id, user); @@ -173,6 +193,12 @@ function InternalGPTChartPage({ Your Position + {user && ( + + + VS. Rivals + + )} @@ -198,22 +224,24 @@ function ChartLeaderboardTable({ data: ChartPBData; user: PublicUserDocument | null; userMap: Map; - mode: "leaderboard" | "adjacent"; + mode: "leaderboard" | "adjacent" | "rivals"; chart: ChartDocument; song: SongDocument; } & GamePT) { const dataset: PBDataset = useMemo(() => { const ds: PBDataset = []; - let pbs = []; + let pbs: Array = []; if (mode === "leaderboard") { pbs = data.leaderboard.pbs; - } else { + } else if (mode === "adjacent") { pbs = [ ...data.adjacent!.adjacentAbove, data.adjacent!.pb, ...data.adjacent!.adjacentBelow, ]; + } else if (mode === "rivals") { + pbs = data.rivals!.pbs; } for (const pb of pbs) { @@ -233,6 +261,7 @@ function ChartLeaderboardTable({ return ( ) : ( - + )} @@ -165,78 +169,3 @@ function ProfileLeaderboard({ game, playtype }: GamePT) { ); } - -function ScoreLeaderboard({ game, playtype }: GamePT) { - const gptConfig = GetGamePTConfig(game, playtype); - - const defaultAlg = useScoreRatingAlg(game, playtype); - - const [alg, setAlg] = useState(defaultAlg); - - const SelectComponent = - gptConfig.scoreRatingAlgs.length > 1 ? ( - setAlg(e.target.value as any)}> - {gptConfig.scoreRatingAlgs.map((e) => ( - - ))} - - ) : null; - - const { data, error } = useApiQuery( - `/games/${game}/${playtype}/score-leaderboard?alg=${alg}` - ); - - if (error) { - return ( - <> - {SelectComponent} - - - ); - } - - if (!data) { - return ( - <> - {SelectComponent} - - - ); - } - - const songMap = CreateSongMap(data.songs); - const chartMap = CreateChartMap(data.charts); - const userMap = CreateUserMap(data.users); - - const pbDataset: PBDataset = []; - - for (const [index, pb] of data.pbs.entries()) { - pbDataset.push({ - ...pb, - __related: { - chart: chartMap.get(pb.chartID)!, - song: songMap.get(pb.songID)!, - index, - user: userMap.get(pb.userID)!, - }, - }); - } - - return ( - <> - {SelectComponent} - - - - ); -} diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx index 8fa9a5934..e66731f44 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx @@ -27,6 +27,7 @@ import { UnsuccessfulAPIResponse, } from "tachi-common"; import { GamePT, SetState } from "types/react"; +import usePreferredRanking from "components/util/usePreferredRanking"; export default function ScoresPage({ reqUser, @@ -177,6 +178,8 @@ function PBsOverview({ const { data, error } = useFetchPBs(url, reqUser); + const preferredRanking = usePreferredRanking(); + return (
@@ -195,7 +198,8 @@ function PBsOverview({ showPlaycount={showPlaycount} indexCol={indexCol} alg={alg} - playtype={playtype as "SP" | "DP"} + playtype={playtype} + defaultRankingViewMode={preferredRanking} /> ) : ( diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx index 0150bf063..0b74228f9 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx @@ -105,6 +105,7 @@ function PreferencesForm({ reqUser, game, playtype }: Props) { gameSpecific: settings!.preferences.gameSpecific as any, defaultTable: settings!.preferences.defaultTable, scoreBucket: settings!.preferences.scoreBucket ?? gptConfig.scoreBucket, + preferredRanking: settings!.preferences.preferredRanking ?? "global", }, onSubmit: async (values) => { const rj = await APIFetchV1( @@ -222,6 +223,21 @@ function PreferencesForm({ reqUser, game, playtype }: Props) { breakdown. You can still view all the same stats! + + Preferred Ranking + + + + + + What should {TachiConfig.name} default to when showing your score rankings? + + Preferred Table + +
+ + + Compare Top 100s + + + + Best PBs + + + + Folder Comparisons + +
+ + + + + + top100 + + + + + + + + folders + + + + + ); +} diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx index c805c0b5d..816d452cc 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx @@ -4,10 +4,11 @@ import Icon from "components/util/Icon"; import SelectLinkButton from "components/util/SelectLinkButton"; import useUGPTBase from "components/util/useUGPTBase"; import React from "react"; -import { Row, Col } from "react-bootstrap"; -import { Switch, Route } from "react-router-dom"; +import { Col, Row } from "react-bootstrap"; +import { Route, Switch } from "react-router-dom"; import { FormatGame, Game, GetGameConfig, Playtype, PublicUserDocument } from "tachi-common"; -import RivalsOverviewPage from "./RivalsOverviewPage"; +import RivalsComparePage from "./RivalsComparePage"; +import RivalsManagePage from "./RivalsManagePage"; export default function RivalsMainPage({ reqUser, @@ -32,34 +33,59 @@ export default function RivalsMainPage({
- - - Rival Score Feed + + + Goals & Milestones + + + + Compare + + Activity + + + + Manage Folders + + Manage Rivals - - - Pinned Charts -
- + activity + + path="/dashboard/users/:userID/games/:game/:playtype/rivals/manage" + > + + + + path="/dashboard/users/:userID/games/:game/:playtype/rivals/folders" + > + folders + + + + goals milestones + + + + +
diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsOverviewPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsManagePage.tsx similarity index 68% rename from client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsOverviewPage.tsx rename to client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsManagePage.tsx index e2647896b..2d857089c 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsOverviewPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsManagePage.tsx @@ -10,11 +10,13 @@ import Muted from "components/util/Muted"; import useApiQuery from "components/util/query/useApiQuery"; import UserIcon from "components/util/UserIcon"; import { UserContext } from "context/UserContext"; -import React, { useContext, useEffect, useState } from "react"; +import React, { useContext, useState } from "react"; import { Button, Col } from "react-bootstrap"; -import { Game, Playtype, PublicUserDocument } from "tachi-common"; +import { FormatGame, Game, GetGameConfig, Playtype, PublicUserDocument } from "tachi-common"; +import useLUGPTSettings from "components/util/useLUGPTSettings"; +import useSetSubheader from "components/layout/header/useSetSubheader"; -export default function RivalsOverviewWrapper({ +export default function RivalsManagePage({ reqUser, game, playtype, @@ -23,6 +25,14 @@ export default function RivalsOverviewWrapper({ game: Game; playtype: Playtype; }) { + const gameConfig = GetGameConfig(game); + + useSetSubheader( + ["Users", reqUser.username, "Games", gameConfig.name, playtype, "Rivals", "Manage"], + [reqUser, game, playtype], + `Managing ${reqUser.username}'s ${FormatGame(game, playtype)} Rivals` + ); + const { data, error } = useApiQuery( `/users/${reqUser.id}/games/${game}/${playtype}/rivals` ); @@ -79,29 +89,15 @@ function RivalsOverviewPage({ const isRequestingUser = reqUser.id === user?.id; - const [rivals, setRivals] = useState>(initialRivals); + const [rivals, setRivals] = useState(initialRivals); const [show, setShow] = useState(false); + const { settings, setSettings } = useLUGPTSettings(); - useEffect(() => { - if (!isRequestingUser) { - return; - } + const [currentRivals, setCurrentRivals] = useState(initialRivals); - APIFetchV1( - `/users/${reqUser.id}/games/${game}/${playtype}/rivals`, - { - method: "PUT", - body: JSON.stringify({ - rivalIDs: rivals.map((e) => e.id), - }), - headers: { - "Content-Type": "application/json", - }, - }, - false, - true - ); - }, [rivals]); + if (!settings) { + return
Looks like you're not signed in. How did you get to this page?
; + } return ( <> @@ -144,12 +140,58 @@ function RivalsOverviewPage({ )} + {/* kind of a stupid way to check whether the array has changed or not, but who cares. */} + {currentRivals.toString() !== rivals.toString() && ( + <> + + + + + + + + )} + { if (rivals.length >= 5) { SendErrorToast(`Can't have more than 5 rivals!`); } else { setRivals([...rivals, user]); + + // if we're now at max rivals, exit. + if (rivals.length + 1 >= 5) { + setShow(false); + } } }} show={show} diff --git a/client/src/app/routes/GameRoutes.tsx b/client/src/app/routes/GameRoutes.tsx index 5805072c7..209a77b3c 100644 --- a/client/src/app/routes/GameRoutes.tsx +++ b/client/src/app/routes/GameRoutes.tsx @@ -37,6 +37,7 @@ import { import { SongsReturn } from "types/api-returns"; import { GamePT, SetState } from "types/react"; import useLUGPTSettings from "components/util/useLUGPTSettings"; +import { UGPTContextProvider } from "context/UGPTContext"; export default function GameRoutes() { const { game } = useParams<{ game: string }>(); @@ -66,7 +67,9 @@ export default function GameRoutes() { - + + + diff --git a/client/src/components/game/ScoreLeaderboard.tsx b/client/src/components/game/ScoreLeaderboard.tsx new file mode 100644 index 000000000..44b43bdd9 --- /dev/null +++ b/client/src/components/game/ScoreLeaderboard.tsx @@ -0,0 +1,146 @@ +import { DEFAULT_BAR_PROPS } from "util/charts"; +import { CreateChartMap, CreateUserMap } from "util/data"; +import { UppercaseFirst } from "util/misc"; +import { NumericSOV, StrSOV } from "util/sorts"; +import { ResponsiveBar } from "@nivo/bar"; +import PBTable from "components/tables/pbs/PBTable"; +import ApiError from "components/util/ApiError"; +import Loading from "components/util/Loading"; +import useApiQuery from "components/util/query/useApiQuery"; +import useScoreRatingAlg from "components/util/useScoreRatingAlg"; +import React, { useState } from "react"; +import { Form } from "react-bootstrap"; +import { COLOUR_SET, CreateSongMap, GetGamePTConfig, integer } from "tachi-common"; +import { ScoreLeaderboardReturns } from "types/api-returns"; +import { GamePT } from "types/react"; +import { PBDataset } from "types/tables"; +import Divider from "components/util/Divider"; + +const USER_COLOURS = [ + COLOUR_SET.blue, + COLOUR_SET.red, + COLOUR_SET.green, + COLOUR_SET.pink, + COLOUR_SET.purple, + COLOUR_SET.orange, +]; + +export default function ScoreLeaderboard({ game, playtype, url }: GamePT & { url: string }) { + const gptConfig = GetGamePTConfig(game, playtype); + + const defaultAlg = useScoreRatingAlg(game, playtype); + + const [alg, setAlg] = useState(defaultAlg); + + const SelectComponent = + gptConfig.scoreRatingAlgs.length > 1 ? ( + setAlg(e.target.value as any)}> + {gptConfig.scoreRatingAlgs.map((e) => ( + + ))} + + ) : null; + + const { data, error } = useApiQuery(`${url}?alg=${alg}`); + + if (error) { + return ( + <> + {SelectComponent} + + + ); + } + + if (!data) { + return ( + <> + {SelectComponent} + + + ); + } + + const songMap = CreateSongMap(data.songs); + const chartMap = CreateChartMap(data.charts); + const userMap = CreateUserMap(data.users); + + const pbDataset: PBDataset = []; + + for (const [index, pb] of data.pbs.entries()) { + pbDataset.push({ + ...pb, + __related: { + chart: chartMap.get(pb.chartID)!, + song: songMap.get(pb.songID)!, + index, + user: userMap.get(pb.userID)!, + }, + }); + } + + return ( + <> + {SelectComponent} + + + + + + ); +} + +function DistributionChart({ dataset }: { dataset: PBDataset }) { + // username -> scores in the top N + const dist: Record = {}; + + for (const pb of dataset) { + const key = pb.__related.user?.username; + + if (key === undefined) { + // shouldn't be possible, but lets not have the ui crash + // because of something so menial + continue; + } + + if (key in dist) { + dist[key]++; + } else { + dist[key] = 1; + } + } + + const aggregatedData = Object.entries(dist) + .map(([username, count]) => ({ username, count })) + .sort(NumericSOV((x) => x.count)); + + const usernames = [...new Set(aggregatedData.map((e) => e.username))]; + + return ( +
+ + USER_COLOURS[(usernames.indexOf(x.data.username) ?? 0) % USER_COLOURS.length] + } + margin={{ left: 80, bottom: 40, top: 20, right: 20 }} + tooltip={() => <>} + {...DEFAULT_BAR_PROPS} + /> +
+ ); +} diff --git a/client/src/components/tables/cells/IndexCell.tsx b/client/src/components/tables/cells/IndexCell.tsx index 3a03008d1..6d62ac08f 100644 --- a/client/src/components/tables/cells/IndexCell.tsx +++ b/client/src/components/tables/cells/IndexCell.tsx @@ -1,6 +1,7 @@ import React from "react"; import { integer } from "tachi-common"; +// gold, silver, bronze const COLORS = ["rgba(212,175,55,0.2)", "rgba(192,192,192,0.2)", "rgba(139,69,19,0.2)"]; export default function IndexCell({ index }: { index: integer }) { diff --git a/client/src/components/tables/cells/RankingCell.tsx b/client/src/components/tables/cells/RankingCell.tsx index 48a033dc5..7d1d863b5 100644 --- a/client/src/components/tables/cells/RankingCell.tsx +++ b/client/src/components/tables/cells/RankingCell.tsx @@ -1,15 +1,73 @@ +import useLUGPTSettings from "components/util/useLUGPTSettings"; import React from "react"; +import { integer } from "rg-stats/js/util/types"; import { PBScoreDocument } from "tachi-common"; +export type RankingViewMode = "global" | "rival" | "both-if-self" | "global-no-switch"; + export default function RankingCell({ rankingData, + userID, + rankingViewMode, }: { rankingData: PBScoreDocument["rankingData"]; + userID: integer; + rankingViewMode: RankingViewMode; }) { - return ( - - #{rankingData.rank} - /{rankingData.outOf} - - ); + const { settings } = useLUGPTSettings(); + + switch (rankingViewMode) { + case "global": + case "global-no-switch": + return ( + + #{rankingData.rank} + /{rankingData.outOf} + + ); + case "rival": + if (!settings) { + return ( + + No Settings, yet tried to view rival stats? not possible. how'd you get + here. report this. + + ); + } + + if (settings?.userID !== userID) { + return ( + + N/A + + ); + } + + return ( + + #{rankingData.rivalRank} + /{settings.rivals.length + 1} + + ); + + case "both-if-self": + if (settings?.userID === userID && rankingData.rivalRank !== null) { + return ( + + Global #{rankingData.rank} + /{rankingData.outOf} +
+ Rival #{rankingData.rivalRank} + /{settings.rivals.length + 1} + + ); + } + + return ( + + #{rankingData.rank} + /{rankingData.outOf} + + ); + } } diff --git a/client/src/components/tables/components/DropdownRow.tsx b/client/src/components/tables/components/DropdownRow.tsx index 5c4557c41..1fea2f404 100644 --- a/client/src/components/tables/components/DropdownRow.tsx +++ b/client/src/components/tables/components/DropdownRow.tsx @@ -6,7 +6,8 @@ export default function DropdownRow({ children, className, dropdown, -}: { dropdown: React.ReactNode; className?: string } & JustChildren) { + nested = false, +}: { dropdown: React.ReactNode; className?: string; nested?: boolean } & JustChildren) { const [showDropdown, setShowDropdown] = useState(false); const [renderDropdown, setRenderDropdown] = useState(false); @@ -34,8 +35,8 @@ export default function DropdownRow({ setShowDropdown(!showDropdown)}> {children} - - + + {renderDropdown ? (
{dropdown}
diff --git a/client/src/components/tables/components/SelectableRanking.tsx b/client/src/components/tables/components/SelectableRanking.tsx new file mode 100644 index 000000000..84ddef0da --- /dev/null +++ b/client/src/components/tables/components/SelectableRanking.tsx @@ -0,0 +1,64 @@ +import useLUGPTSettings from "components/util/useLUGPTSettings"; +import React from "react"; +import { SetState } from "types/react"; +import { RankingViewMode } from "../cells/RankingCell"; +import SortableTH from "./SortableTH"; +import { ZTableTHProps } from "./TachiTable"; + +export default function SelectableRanking({ + rankingViewMode, + setRankingViewMode, + changeSort, + currentSortMode, + reverseSort, +}: { + rankingViewMode: RankingViewMode; + setRankingViewMode: SetState; +} & ZTableTHProps) { + const { settings } = useLUGPTSettings(); + + if (rankingViewMode === "both-if-self" || rankingViewMode === "global-no-switch" || !settings) { + return ( + + ); + } + + return ( + + +
+ changeSort("Site Ranking")}> + + + + + ); +} diff --git a/client/src/components/tables/dropdowns/PBDropdown.tsx b/client/src/components/tables/dropdowns/PBDropdown.tsx index e699edbb9..c42886abe 100644 --- a/client/src/components/tables/dropdowns/PBDropdown.tsx +++ b/client/src/components/tables/dropdowns/PBDropdown.tsx @@ -15,6 +15,7 @@ import DocComponentCreator, { } from "./components/DocumentComponent"; import DropdownStructure from "./components/DropdownStructure"; import PlayHistory from "./components/PlayHistory"; +import RivalCompare from "./components/RivalCompare"; import { GPTDropdownSettings } from "./GPTDropdownSettings"; export interface ScoreDropdownProps { @@ -35,13 +36,11 @@ export default function PBDropdown({ userID: integer; chart: ChartDocument; scoreState: ScoreState; - defaultView?: "pb" | "scorePB" | "lampPB" | "history" | "debug"; + defaultView?: "pb" | "scorePB" | "lampPB" | "history" | "debug" | "rivals"; } & GamePT) { const DocComponent: DocumentComponentType = (props) => DocComponentCreator({ ...props, ...GPTDropdownSettings(game, playtype) }); - // const { settings } = useContext(UserSettingsContext); - const [view, setView] = useState(defaultView); const { data, error } = useApiQuery>( @@ -109,6 +108,8 @@ export default function PBDropdown({ ); } else if (view === "debug") { body = ; + } else if (view === "rivals") { + body = ; } else { body = ( ({ Play History{histData && ` (${histData.length})`} + + + Rivals + diff --git a/client/src/components/tables/dropdowns/ScoreDropdown.tsx b/client/src/components/tables/dropdowns/ScoreDropdown.tsx index e0a72756e..cf2b89695 100644 --- a/client/src/components/tables/dropdowns/ScoreDropdown.tsx +++ b/client/src/components/tables/dropdowns/ScoreDropdown.tsx @@ -21,6 +21,7 @@ import DropdownStructure from "./components/DropdownStructure"; import ManageScore from "./components/ManageScore"; import PBCompare from "./components/PBCompare"; import PlayHistory from "./components/PlayHistory"; +import RivalCompare from "./components/RivalCompare"; import { GPTDropdownSettings } from "./GPTDropdownSettings"; export interface ScoreState { @@ -45,7 +46,7 @@ export default function ScoreDropdown({ user: PublicUserDocument; chart: ChartDocument; scoreState: ScoreState; - defaultView?: "vsPB" | "moreInfo" | "history" | "debug" | "manage"; + defaultView?: "vsPB" | "moreInfo" | "history" | "debug" | "manage" | "rivals"; thisScore: ScoreDocument; } & GamePT) { const DocComponent: DocumentComponentType = (props) => @@ -106,6 +107,8 @@ export default function ScoreDropdown({ body = ; } else if (view === "manage") { body = ; + } else if (view === "rivals") { + body = ; } return ( @@ -130,6 +133,12 @@ export default function ScoreDropdown({ Manage Score )} + {currentUser && ( + + + Rivals + + )} diff --git a/client/src/components/tables/dropdowns/components/DocumentComponent.tsx b/client/src/components/tables/dropdowns/components/DocumentComponent.tsx index f3b04ce2b..57a0df5e2 100644 --- a/client/src/components/tables/dropdowns/components/DocumentComponent.tsx +++ b/client/src/components/tables/dropdowns/components/DocumentComponent.tsx @@ -152,3 +152,43 @@ export default function DocumentComponent({ ); } + +export function GraphAndJudgementDataComponent({ + score, + GraphComponent = null, + chart, +}: { + score: ScoreDocument | PBScoreDocument; + renderScoreInfo?: boolean; + showSingleScoreNote?: boolean; + forceScoreData?: boolean; + chart: ChartDocument; + GraphComponent?: + | (({ + score, + chart, + }: { + score: ScoreDocument | PBScoreDocument; + chart: ChartDocument; + }) => JSX.Element) + | null; +}) { + return ( +
+
+
+ {GraphComponent ? ( + + ) : ( +
+ No graphs available :( +
+ )} +
+
+
+ +
+
+ ); +} diff --git a/client/src/components/tables/dropdowns/components/PlayHistory.tsx b/client/src/components/tables/dropdowns/components/PlayHistory.tsx index 9810fe493..c2103530c 100644 --- a/client/src/components/tables/dropdowns/components/PlayHistory.tsx +++ b/client/src/components/tables/dropdowns/components/PlayHistory.tsx @@ -25,7 +25,7 @@ export default function PlayHistory({ } return ( -
+
); diff --git a/client/src/components/tables/dropdowns/components/RivalCompare.tsx b/client/src/components/tables/dropdowns/components/RivalCompare.tsx new file mode 100644 index 000000000..34abdad67 --- /dev/null +++ b/client/src/components/tables/dropdowns/components/RivalCompare.tsx @@ -0,0 +1,64 @@ +import { NumericSOV } from "util/sorts"; +import RivalChartTable from "components/tables/rivals/RivalChartTable"; +import ApiError from "components/util/ApiError"; +import Loading from "components/util/Loading"; +import useApiQuery from "components/util/query/useApiQuery"; +import useUGPTBase from "components/util/useUGPTBase"; +import { UserContext } from "context/UserContext"; +import React, { useContext } from "react"; +import { Link } from "react-router-dom"; +import { ChartDocument, Game } from "tachi-common"; +import { ChartRivalsReturn } from "types/api-returns"; +import { RivalChartDataset } from "types/tables"; + +export default function RivalCompare({ chart, game }: { chart: ChartDocument; game: Game }) { + const { user: currentUser } = useContext(UserContext); + + const playtype = chart.playtype; + + if (!currentUser) { + return
You're not signed in. How did you even get to this page?
; + } + + const base = useUGPTBase({ reqUser: currentUser, game, playtype }); + + const { data, error } = useApiQuery( + `/users/${currentUser.id}/games/${game}/${playtype}/pbs/${chart.chartID}/rivals` + ); + + if (!data) { + return ; + } + + if (error) { + return ; + } + + if (data.rivals.length === 0) { + return ( +
+ You have no rivals set! +
+ Why not set some? +
+ ); + } + + const rivalDataset: RivalChartDataset = [...data.rivals, currentUser] + .map((u) => ({ + ...u, + __related: { + pb: data.pbs.find((p) => p.userID === u.id) ?? null, + }, + })) + .sort(NumericSOV((x) => x.__related.pb?.scoreData.percent ?? -Infinity, true)) + .map((e, index) => ({ + ...e, + __related: { + ...e.__related, + index, + }, + })); + + return ; +} diff --git a/client/src/components/tables/folders/FolderTable.tsx b/client/src/components/tables/folders/FolderTable.tsx index 88ad80192..6ec8df73d 100644 --- a/client/src/components/tables/folders/FolderTable.tsx +++ b/client/src/components/tables/folders/FolderTable.tsx @@ -1,14 +1,15 @@ import { NumericSOV, StrSOV } from "util/sorts"; import { CreateDefaultFolderSearchParams } from "util/tables/create-search"; +import Muted from "components/util/Muted"; +import usePreferredRanking from "components/util/usePreferredRanking"; import useScoreRatingAlg from "components/util/useScoreRatingAlg"; import React, { useState } from "react"; import { Game, IDStrings, ScoreCalculatedDataLookup } from "tachi-common"; import { FolderDataset } from "types/tables"; import { Playtype } from "types/tachi"; -import Muted from "components/util/Muted"; import DifficultyCell from "../cells/DifficultyCell"; import IndicatorsCell from "../cells/IndicatorsCell"; -import RankingCell from "../cells/RankingCell"; +import RankingCell, { RankingViewMode } from "../cells/RankingCell"; import TimestampCell from "../cells/TimestampCell"; import TitleCell from "../cells/TitleCell"; import DropdownRow from "../components/DropdownRow"; @@ -19,6 +20,7 @@ import ScoreCoreCells from "../game-core-cells/ScoreCoreCells"; import ChartHeader from "../headers/ChartHeader"; import { GetGPTCoreHeaders } from "../headers/GameHeaders"; import { EmptyHeader, FolderIndicatorHeader } from "../headers/IndicatorHeader"; +import { CreateRankingHeader } from "../headers/RankingHeader"; export default function FolderTable({ dataset, @@ -31,7 +33,12 @@ export default function FolderTable({ }) { const defaultRating = useScoreRatingAlg(game, playtype); + const preferredRanking = usePreferredRanking(); + const [rating, setRating] = useState(defaultRating); + const [rankingViewMode, setRankingViewMode] = useState( + preferredRanking ?? "global" + ); const headers: Header[] = [ ChartHeader(game, (k) => k), @@ -45,11 +52,11 @@ export default function FolderTable({ setRating, (x) => x.__related.pb ), - [ - "Site Ranking", - "Site Rank", - NumericSOV((x) => x.__related.pb?.rankingData.rank ?? -Infinity), - ], + CreateRankingHeader( + rankingViewMode, + setRankingViewMode, + (k) => k.__related.pb?.rankingData + ), ["Last Raised", "Last Raised", NumericSOV((x) => x.__related.pb?.timeAchieved ?? 0)], ]; @@ -60,7 +67,13 @@ export default function FolderTable({ entryName="Charts" searchFunctions={CreateDefaultFolderSearchParams(game, playtype)} rowFunction={(data) => ( - + )} /> ); @@ -70,10 +83,12 @@ function Row({ data, rating, game, + rankingViewMode, }: { data: FolderDataset[0]; game: Game; rating: ScoreCalculatedDataLookup[I]; + rankingViewMode: RankingViewMode; }) { const score = data.__related.pb; @@ -110,7 +125,11 @@ function Row({ PB - + ); diff --git a/client/src/components/tables/headers/GameHeaders.tsx b/client/src/components/tables/headers/GameHeaders.tsx index 67edcd08f..76a3ab57a 100644 --- a/client/src/components/tables/headers/GameHeaders.tsx +++ b/client/src/components/tables/headers/GameHeaders.tsx @@ -10,12 +10,14 @@ import { ScoreDocument, } from "tachi-common"; import { SetState } from "types/react"; -import { FolderDataset, PBDataset, ScoreDataset } from "types/tables"; +import { FolderDataset, PBDataset, RivalChartDataset, ScoreDataset } from "types/tables"; import { Playtype } from "types/tachi"; import SelectableRating from "../components/SelectableRating"; import { Header, ZTableTHProps } from "../components/TachiTable"; -export function GetGPTCoreHeaders( +export function GetGPTCoreHeaders< + Dataset extends FolderDataset | PBDataset | ScoreDataset | RivalChartDataset +>( game: Game, playtype: Playtype, rating: ScoreCalculatedDataLookup[IDStrings], diff --git a/client/src/components/tables/headers/RankingHeader.tsx b/client/src/components/tables/headers/RankingHeader.tsx new file mode 100644 index 000000000..9f3b28fa8 --- /dev/null +++ b/client/src/components/tables/headers/RankingHeader.tsx @@ -0,0 +1,37 @@ +import { NumericSOV } from "util/sorts"; +import React from "react"; +import { PBScoreDocument } from "tachi-common"; +import { SetState } from "types/react"; +import { RankingViewMode } from "../cells/RankingCell"; +import SelectableRanking from "../components/SelectableRanking"; +import { Header, ZTableTHProps } from "../components/TachiTable"; + +export function CreateRankingHeader( + rankingViewMode: RankingViewMode, + setRankingViewMode: SetState, + kMapToRankingData: (k: T) => PBScoreDocument["rankingData"] | undefined +): Header { + return [ + "Site Ranking", + "Site Ranking", + + NumericSOV((x) => { + const rankingData = kMapToRankingData(x); + + if (!rankingData) { + return -Infinity; + } + + return rankingViewMode === "rival" + ? rankingData.rivalRank ?? -Infinity + : rankingData.rank; + }), + (thProps: ZTableTHProps) => ( + + ), + ]; +} diff --git a/client/src/components/tables/history-scores/HistoryScoreTable.tsx b/client/src/components/tables/history-scores/HistoryScoreTable.tsx index a3aafc158..1de8582f8 100644 --- a/client/src/components/tables/history-scores/HistoryScoreTable.tsx +++ b/client/src/components/tables/history-scores/HistoryScoreTable.tsx @@ -15,6 +15,11 @@ import TimestampCell from "../cells/TimestampCell"; import TachiTable from "../components/TachiTable"; import ScoreCoreCells from "../game-core-cells/ScoreCoreCells"; import { GetGPTCoreHeaders } from "../headers/GameHeaders"; +import DropdownRow from "../components/DropdownRow"; +import { GraphAndJudgementDataComponent } from "../dropdowns/components/DocumentComponent"; +import { GPTDropdownSettings } from "../dropdowns/GPTDropdownSettings"; +import { EmptyHeader } from "../headers/IndicatorHeader"; +import DropdownIndicatorCell from "../cells/DropdownIndicatorCell"; export default function HistoryScoreTable({ dataset, @@ -44,6 +49,7 @@ export default function HistoryScoreTable({ headers={[ ...headers, ["Timestamp", "Timestamp", NumericSOV((x) => x.timeAchieved ?? 0)], + EmptyHeader, ]} defaultSortMode="Timestamp" defaultReverseSort @@ -66,9 +72,19 @@ function Row({ chart: ChartDocument; }) { return ( - + + } + > - + + ); } diff --git a/client/src/components/tables/pbs/PBTable.tsx b/client/src/components/tables/pbs/PBTable.tsx index 33091825d..aadc52f87 100644 --- a/client/src/components/tables/pbs/PBTable.tsx +++ b/client/src/components/tables/pbs/PBTable.tsx @@ -8,7 +8,7 @@ import { PBDataset } from "types/tables"; import { Playtype } from "types/tachi"; import DropdownIndicatorCell from "../cells/DropdownIndicatorCell"; import IndexCell from "../cells/IndexCell"; -import RankingCell from "../cells/RankingCell"; +import RankingCell, { RankingViewMode } from "../cells/RankingCell"; import TimestampCell from "../cells/TimestampCell"; import DropdownRow from "../components/DropdownRow"; import TachiTable, { Header } from "../components/TachiTable"; @@ -18,6 +18,7 @@ import ScoreCoreCells from "../game-core-cells/ScoreCoreCells"; import ChartHeader from "../headers/ChartHeader"; import { GetGPTCoreHeaders } from "../headers/GameHeaders"; import { EmptyHeader } from "../headers/IndicatorHeader"; +import { CreateRankingHeader } from "../headers/RankingHeader"; import PBLeadingRows from "./PBLeadingRows"; export default function PBTable({ @@ -29,6 +30,7 @@ export default function PBTable({ showChart = true, playtype, alg, + defaultRankingViewMode, }: { dataset: PBDataset; indexCol?: boolean; @@ -38,10 +40,14 @@ export default function PBTable({ playtype: Playtype; game: Game; alg?: ScoreCalculatedDataLookup[I]; + defaultRankingViewMode?: RankingViewMode | null; }) { const defaultRating = useScoreRatingAlg(game, playtype); const [rating, setRating] = useState(alg ?? defaultRating); + const [rankingViewMode, setRankingViewMode] = useState( + defaultRankingViewMode ?? "global" + ); const headers: Header[0]>[] = [ ...GetPBLeadingHeaders( @@ -51,7 +57,7 @@ export default function PBTable({ ), EmptyHeader, ...GetGPTCoreHeaders(game, playtype, rating, setRating, (x) => x), - ["Site Ranking", "Site Rank", NumericSOV((x) => x.rankingData.rank)], + CreateRankingHeader(rankingViewMode, setRankingViewMode, (k) => k.rankingData), ["Last Raised", "Last Raised", NumericSOV((x) => x.timeAchieved ?? 0)], EmptyHeader, ]; @@ -86,6 +92,7 @@ export default function PBTable({ rating={rating} game={game} playtype={playtype} + rankingViewMode={rankingViewMode} /> )} /> @@ -100,6 +107,7 @@ function Row({ showUser, game, rating, + rankingViewMode, }: { pb: PBDataset[0]; indexCol: boolean; @@ -110,6 +118,7 @@ function Row({ playtype: Playtype; // ts bug? rating: any; // ScoreCalculatedDataLookup[I]; + rankingViewMode: RankingViewMode; }) { const scoreState = usePBState(pb); @@ -128,7 +137,11 @@ function Row({ {indexCol && } - + {showPlaycount && {pb.__playcount ?? 0}} diff --git a/client/src/components/tables/rivals/RivalChartTable.tsx b/client/src/components/tables/rivals/RivalChartTable.tsx new file mode 100644 index 000000000..03a5495eb --- /dev/null +++ b/client/src/components/tables/rivals/RivalChartTable.tsx @@ -0,0 +1,120 @@ +import { NumericSOV, StrSOV } from "util/sorts"; +import useScoreRatingAlg from "components/util/useScoreRatingAlg"; +import React, { useState } from "react"; +import { ChartDocument, Game, IDStrings, ScoreCalculatedDataLookup } from "tachi-common"; +import { RivalChartDataset } from "types/tables"; +import IndexCell from "../cells/IndexCell"; +import RankingCell, { RankingViewMode } from "../cells/RankingCell"; +import TimestampCell from "../cells/TimestampCell"; +import UserCell from "../cells/UserCell"; +import DropdownRow from "../components/DropdownRow"; +import TachiTable, { Header } from "../components/TachiTable"; +import { GraphAndJudgementDataComponent } from "../dropdowns/components/DocumentComponent"; +import { GPTDropdownSettings } from "../dropdowns/GPTDropdownSettings"; +import ScoreCoreCells from "../game-core-cells/ScoreCoreCells"; +import { GetGPTCoreHeaders } from "../headers/GameHeaders"; +import { CreateRankingHeader } from "../headers/RankingHeader"; + +export default function RivalChartTable({ + dataset, + game, + chart, +}: { + dataset: RivalChartDataset; + game: Game; + chart: ChartDocument; +}) { + const playtype = chart.playtype; + + const defaultRating = useScoreRatingAlg(game, playtype); + + const [rating, setRating] = useState(defaultRating); + const [rankingViewMode, setRankingViewMode] = useState("global"); + + const headers: Header[] = [ + ["#", "#", NumericSOV((x) => x.__related.index)], + ["User", "User", StrSOV((x) => x.username)], + ...GetGPTCoreHeaders( + game, + playtype, + rating, + setRating, + (x) => x.__related.pb + ), + CreateRankingHeader( + rankingViewMode, + setRankingViewMode, + (k) => k.__related.pb?.rankingData + ), + ["Last Raised", "Last Raised", NumericSOV((x) => x.__related.pb?.timeAchieved ?? 0)], + ]; + + return ( + ( + + )} + /> + ); +} + +function Row({ + data, + rating, + game, + chart, + rankingViewMode, +}: { + data: RivalChartDataset[0]; + game: Game; + rating: ScoreCalculatedDataLookup[I]; + chart: ChartDocument; + rankingViewMode: RankingViewMode; +}) { + const pb = data.__related.pb; + + if (!pb) { + return ( + + N/A + + Not Played. + + ); + } + + return ( + + } + > + + + + + + + ); +} diff --git a/client/src/components/user/UGPTHeader.tsx b/client/src/components/user/UGPTHeader.tsx index 145a97dfb..f17e8d29d 100644 --- a/client/src/components/user/UGPTHeader.tsx +++ b/client/src/components/user/UGPTHeader.tsx @@ -104,7 +104,7 @@ export function UGPTBottomNav({ Rivals , - Targets + Goals & Milestones , ]; diff --git a/client/src/components/util/SelectLinkButton.tsx b/client/src/components/util/SelectLinkButton.tsx index 69058c82a..7d229f55a 100644 --- a/client/src/components/util/SelectLinkButton.tsx +++ b/client/src/components/util/SelectLinkButton.tsx @@ -9,15 +9,21 @@ export default function SelectLinkButton({ onVariant = "primary", offVariant = "outline-secondary", to, + matchIfStartsWith = false, }: { onVariant?: ButtonVariant; offVariant?: ButtonVariant; to: string; + matchIfStartsWith?: boolean; } & JustChildren) { return ( {children} diff --git a/client/src/components/util/modal/UserSelectModal.tsx b/client/src/components/util/modal/UserSelectModal.tsx index 311484f6c..c1783fddb 100644 --- a/client/src/components/util/modal/UserSelectModal.tsx +++ b/client/src/components/util/modal/UserSelectModal.tsx @@ -67,7 +67,7 @@ export default function UserSelectModal({

{user.username}

{excludeSet.includes(user.id) ? ( ) : (