diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 2f2a60ba1..f48c98745 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -143,7 +143,7 @@ dependencies: resolve: 1.12.0 resolve-url-loader: 3.1.0 socicon: 3.0.5 - tachi-common: github.com/zkldi/tachi-common/21c3bb4cc4407601a902e049fb1e1b774b793e40_typescript@4.3.5 + tachi-common: github.com/zkldi/tachi-common/353c7dcee5ca01dc25324cbb0e8404e5bf80ce1d_typescript@4.3.5 devDependencies: '@types/humanize-duration': 3.25.1 @@ -3102,8 +3102,8 @@ packages: '@babel/runtime': 7.14.6 dev: false - /@types/bson/4.0.4: - resolution: {integrity: sha512-awqorHvQS0DqxkHQ/FxcPX9E+H7Du51Qw/2F+5TBMSaE3G0hm+8D3eXJ6MAzFw75nE8V7xF0QvzUSdxIjJb/GA==} + /@types/bson/4.0.5: + resolution: {integrity: sha512-vVLwMUqhYJSQ/WKcE60eFqcyuWse5fGH+NMAXHuKrUAPoryq3ATxk5o4bgYNtg5aOM4APVg7Hnb3ASqUYG0PKg==} dependencies: '@types/node': 16.0.0 dev: false @@ -3154,7 +3154,7 @@ packages: /@types/mongodb/3.6.20: resolution: {integrity: sha512-WcdpPJCakFzcWWD9juKoZbRtQxKIMYF/JIAM4JrNHrMcnJL6/a2NWjXxW7fo9hxboxxkg+icff8d7+WIEvKgYQ==} dependencies: - '@types/bson': 4.0.4 + '@types/bson': 4.0.5 '@types/node': 16.0.0 dev: false @@ -8462,7 +8462,7 @@ packages: bl: 2.2.1 bson: 1.1.6 denque: 1.5.0 - optional-require: 1.1.0 + optional-require: 1.1.1 safe-buffer: 5.2.1 optionalDependencies: saslprep: 1.0.3 @@ -8926,8 +8926,8 @@ packages: webpack: 4.41.0 dev: false - /optional-require/1.1.0: - resolution: {integrity: sha512-5/7ee3eTFg1P+F9usTubuNCLfWRK6DjV0EFHLlbp7MmV5UlWqpWIVSnH6xo4u+fc5WHXaJuvJi6iuYnfDyj6oQ==} + /optional-require/1.1.1: + resolution: {integrity: sha512-EnUe33GTAltyZlIsQ2l93KzBC9zi8BsxLvKP3wxALOsz/YIakVojyuZsv5PFFk8y8e6r+SbaPIsNmyPoSK0OHw==} engines: {node: '>=4'} dependencies: require-at: 1.0.6 @@ -10435,8 +10435,8 @@ packages: react-dom: 16.12.0_react@17.0.2 dev: false - /react/16.12.0: - resolution: {integrity: sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA==} + /react/16.14.0: + resolution: {integrity: sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==} engines: {node: '>=0.10.0'} dependencies: loose-envify: 1.4.0 @@ -11732,7 +11732,7 @@ packages: mkdirp: 1.0.4 nyc: 15.1.0 opener: 1.5.2 - react: 16.12.0 + react: 16.14.0 rimraf: 3.0.2 signal-exit: 3.0.3 source-map-support: 0.5.19 @@ -12799,9 +12799,9 @@ packages: yargs-parser: 18.1.3 dev: false - github.com/zkldi/tachi-common/21c3bb4cc4407601a902e049fb1e1b774b793e40_typescript@4.3.5: - resolution: {tarball: https://codeload.github.com/zkldi/tachi-common/tar.gz/21c3bb4cc4407601a902e049fb1e1b774b793e40} - id: github.com/zkldi/tachi-common/21c3bb4cc4407601a902e049fb1e1b774b793e40 + github.com/zkldi/tachi-common/353c7dcee5ca01dc25324cbb0e8404e5bf80ce1d_typescript@4.3.5: + resolution: {tarball: https://codeload.github.com/zkldi/tachi-common/tar.gz/353c7dcee5ca01dc25324cbb0e8404e5bf80ce1d} + id: github.com/zkldi/tachi-common/353c7dcee5ca01dc25324cbb0e8404e5bf80ce1d name: tachi-common version: 0.1.0 dependencies: diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx index 027e8a0b4..930292a7d 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx @@ -6,7 +6,7 @@ import { UserContext } from "context/UserContext"; import { UserGameStatsContext } from "context/UserGameStatsContext"; import { nanoid } from "nanoid"; import React, { useContext, useMemo, useState } from "react"; -import { Button, OverlayTrigger, Tooltip } from "react-bootstrap"; +import { Badge, Button, OverlayTrigger, Tooltip } from "react-bootstrap"; import { Link } from "react-router-dom"; import { FormatGame, @@ -20,8 +20,10 @@ import { FolderDocument, GetGamePTConfig, UserGameStats, + SessionDocument, + ScoreDocument, } from "tachi-common"; -import { UGPTHistory, UGPTPreferenceStatsReturn } from "types/api-returns"; +import { SessionReturns, UGPTHistory, UGPTPreferenceStatsReturn } from "types/api-returns"; import { GamePT } from "types/react"; import { APIFetchV1 } from "util/api"; import AsyncLoader from "components/util/AsyncLoader"; @@ -30,9 +32,16 @@ import TimelineChart from "components/charts/TimelineChart"; import Divider from "components/util/Divider"; import Icon from "components/util/Icon"; import { DateTime } from "luxon"; -import { FormatDate, MillisToSince } from "util/time"; +import { FormatDate, FormatDuration, FormatTime, MillisToSince } from "util/time"; import SelectButton from "components/util/SelectButton"; import { UppercaseFirst } from "util/misc"; +import { ONE_HOUR, ONE_MINUTE } from "util/constants/time"; +import MiniTable from "components/tables/components/MiniTable"; +import { GetPBs, CreateChartMap, CreateSongMap } from "util/data"; +import IIDXScoreTable from "components/tables/scores/IIDXScoreTable"; + +import { NumericSOV } from "util/sorts"; +import { ScoreDataset } from "types/tables"; export default function OverviewPage({ reqUser, @@ -51,10 +60,202 @@ export default function OverviewPage({ <> + ); } +function LastSession({ reqUser, game, playtype }: { reqUser: PublicUserDocument } & GamePT) { + return ( + { + const res = await APIFetchV1( + `/users/${reqUser.id}/games/${game}/${playtype}/sessions/last` + ); + + if (res.statusCode === 404) { + return null; + } + + if (!res.success) { + throw new Error(res.description); + } + + const sessionDataRes = await APIFetchV1( + `/sessions/${res.body!.sessionID}` + ); + + if (!sessionDataRes.success) { + throw new Error(res.description); + } + + return { session: res.body, sessionData: sessionDataRes.body }; + }} + > + {data => + data && ( + +
+ { + <> +
+

{data.session.name}

+ {data.session.desc} + +
+ +
+ + + Started + {FormatTime(data.session.timeStarted)} + + + {/* If session ended less than 2 hours ago, it can still be appended to. */} + {/* Kinda - time is a bit nonlinear wrt. importing scores, as scores don't have to have */} + {/* happened at the same time they were imported. */} + {/* that doesn't matter though - this is just a UI thing */} + + {Date.now() - data.session.timeEnded < + ONE_HOUR * 2 + ? "Last Score" + : "Ended At"} + + + {FormatTime(data.session.timeEnded)} + {/* if the last score was less than 10 mins ago, this session is probably still being populated */} + {Date.now() - data.session.timeEnded < + ONE_MINUTE * 10 && ( + <> +
+ + Maybe Ongoing? + + + )} + + + + Duration + + {FormatDuration( + data.session.timeEnded - + data.session.timeStarted + )} + + + + Scores + {data.session.scoreInfo.length} + + + PBs + {GetPBs(data.session.scoreInfo).length} + +
+
+
+ +
+ + } +
+
+ ) + } +
+ ); +} + +function RecentSessionScoreInfo({ + session, + sessionData, + game, + playtype, +}: { + session: SessionDocument; + sessionData: SessionReturns; +} & GamePT) { + const highlightedScores = sessionData.scores.find(e => e.highlight); + + const [mode, setMode] = useState<"highlight" | "best" | "recent">( + highlightedScores ? "highlight" : "best" + ); + + const gptConfig = GetGamePTConfig(game, playtype); + + const songMap = CreateSongMap(sessionData.songs); + const chartMap = CreateChartMap(sessionData.charts); + + const dataset = useMemo(() => { + let scoreSet = sessionData.scores; + if (mode === "highlight") { + scoreSet = sessionData.scores.filter(e => e.highlight); + } else if (mode === "best") { + scoreSet.sort((a, b) => + NumericSOV( + d => d.calculatedData[gptConfig.defaultScoreRatingAlg] ?? 0 + )(b, a) + ); + } else if (mode === "recent") { + // sneaky hack to sort in reverse + scoreSet.sort((a, b) => NumericSOV(d => d.timeAchieved ?? 0)(b, a)); + } + + const scoreDataset: ScoreDataset = []; + + for (const score of scoreSet) { + const chart = chartMap.get(score.chartID)!; + + scoreDataset.push({ + ...score, + __related: { + chart, + song: songMap.get(chart.songID)!, + index: 0, + }, + }); + } + + return scoreDataset; + }, [mode]); + + return ( +
+
+
+ + + Highlights + + + + Best + + + + Recent + +
+
+
+ +
+
+ ); +} + function RankingInfo({ reqUser, game, playtype }: { reqUser: PublicUserDocument } & GamePT) { return (
-
+ {/*
Something
Todo
-
-
+
*/} +
diff --git a/client/src/components/charts/TimelineChart.tsx b/client/src/components/charts/TimelineChart.tsx index 46aac424d..36ed54722 100644 --- a/client/src/components/charts/TimelineChart.tsx +++ b/client/src/components/charts/TimelineChart.tsx @@ -33,12 +33,24 @@ export default function TimelineChart({ style={{ height: mobileHeight, width: mobileWidth }} >
- Not Enough Data... Yet. +
+ Not Enough Data... Yet. +
+ + (You need atleast 7 days worth of data) + +
- Not Enough Data... Yet. +
+ Not Enough Data... Yet. +
+ + (You need atleast 7 days worth of data) + +
diff --git a/client/src/components/game/ClassBadge.tsx b/client/src/components/game/ClassBadge.tsx new file mode 100644 index 000000000..e9e3f51f3 --- /dev/null +++ b/client/src/components/game/ClassBadge.tsx @@ -0,0 +1,50 @@ +import QuickTooltip from "components/layout/misc/QuickTooltip"; +import React from "react"; +import { Badge } from "react-bootstrap"; +import { GetGamePTConfig, IDStrings, integer } from "tachi-common"; +import { GameClassSets } from "tachi-common/js/game-classes"; +import { GamePT } from "types/react"; +import { UppercaseFirst } from "util/misc"; + +export default function ClassBadge({ + game, + playtype, + classSet, + classValue, + showSetOnHover = true, +}: { + classSet: GameClassSets[I]; + classValue: integer; + showSetOnHover?: boolean; +} & GamePT) { + const gptConfig = GetGamePTConfig(game, playtype); + + const data = gptConfig.classHumanisedFormat[classSet][classValue]; + + if (!data) { + throw new Error(`Unknown class value ${classSet}:${classValue}`); + } + + let badgeComponent; + if (data.variant) { + badgeComponent = {data.display}; + } else if (data.css) { + badgeComponent = {data.display}; + } else { + badgeComponent = {data.display}; + } + + if (data.mouseover && showSetOnHover) { + return ( + + {badgeComponent} + + ); + } else if (data.mouseover) { + return {badgeComponent}; + } else if (showSetOnHover) { + {badgeComponent}; + } + + return badgeComponent; +} diff --git a/client/src/components/layout/footer/Footer.tsx b/client/src/components/layout/footer/Footer.tsx index 062b8175d..32c2df277 100644 --- a/client/src/components/layout/footer/Footer.tsx +++ b/client/src/components/layout/footer/Footer.tsx @@ -17,7 +17,11 @@ export function Footer() {
- + window.scrollTo(0, 0)} + > Credits {links}; + return {links}; } diff --git a/client/src/components/layout/header/HeaderMenu.tsx b/client/src/components/layout/header/HeaderMenu.tsx index 004dbc276..3bc1b9f84 100644 --- a/client/src/components/layout/header/HeaderMenu.tsx +++ b/client/src/components/layout/header/HeaderMenu.tsx @@ -66,7 +66,7 @@ export function HeaderMenu() { {/* */} {user && ugs && ugs.length !== 0 && ( - + {ugs.map(e => ( + {headers.map(e => ( diff --git a/client/src/components/tables/scores/IIDXScoreTable.tsx b/client/src/components/tables/scores/IIDXScoreTable.tsx index d91e4360c..2e421fcfd 100644 --- a/client/src/components/tables/scores/IIDXScoreTable.tsx +++ b/client/src/components/tables/scores/IIDXScoreTable.tsx @@ -7,7 +7,7 @@ import { ChangeOpacity } from "util/color-opacity"; import { NumericSOV, StrSOV } from "util/sorts"; import SelectableRating from "../components/SelectableRating"; import { ScoreDataset } from "types/tables"; -import { GetGamePTConfig, ScoreCalculatedDataLookup } from "tachi-common"; +import { GetGamePTConfig, integer, ScoreCalculatedDataLookup } from "tachi-common"; import TachiTable, { Header } from "../components/TachiTable"; import DifficultyCell from "../cells/DifficultyCell"; import ScoreCell from "../cells/ScoreCell"; @@ -15,7 +15,13 @@ import DeltaCell from "../cells/DeltaCell"; import { HumanFriendlyStrToGradeIndex, HumanFriendlyStrToLampIndex } from "util/str-to-num"; import { nanoid } from "nanoid"; -export default function IIDXScoreTable({ dataset }: { dataset: ScoreDataset<"iidx:SP"> }) { +export default function IIDXScoreTable({ + dataset, + pageLen, +}: { + dataset: ScoreDataset<"iidx:SP">; + pageLen?: integer; +}) { const gptConfig = GetGamePTConfig<"iidx:SP">("iidx", "SP"); const [rating, setRating] = useState( @@ -25,6 +31,7 @@ export default function IIDXScoreTable({ dataset }: { dataset: ScoreDataset<"iid return ( x.__related.chart.levelNum)], ["Song", "Song", StrSOV(x => x.__related.song.title)], @@ -85,7 +92,27 @@ export default function IIDXScoreTable({ dataset }: { dataset: ScoreDataset<"iid > {sc.scoreData.lamp}
- [BP: {sc.scoreData.hitMeta.bp ?? "No Data"}] + + [BP: {sc.scoreData.hitMeta.bp ?? "No Data"} + {sc.scoreData.hitMeta.comboBreak + ? `, CBRK: ${sc.scoreData.hitMeta.comboBreak}` + : ""} + ] + + {sc.scoreData.lamp === "FAILED" && ( + <> +
+ + {sc.scoreData.hitMeta.gsm + ? `EC: ${sc.scoreData.hitMeta.gsm.EASY}, NC: ${sc.scoreData.hitMeta.gsm.NORMAL}` + : sc.scoreMeta.gauge === "EASY" + ? `EC: ${sc.scoreData.hitMeta.gauge}` + : sc.scoreMeta.gauge === "NORMAL" + ? `NC: ${sc.scoreMeta.gauge}` + : ""} + + + )} {rating === "BPI" ? ( diff --git a/client/src/components/tables/sessions/GenericSessionTable.tsx b/client/src/components/tables/sessions/GenericSessionTable.tsx index ae0a13a19..8d2d1f161 100644 --- a/client/src/components/tables/sessions/GenericSessionTable.tsx +++ b/client/src/components/tables/sessions/GenericSessionTable.tsx @@ -9,6 +9,7 @@ import { SessionScoreInfo, } from "tachi-common"; import { Playtype } from "types/tachi"; +import { GetPBs } from "util/data"; import { NumericSOV, StrSOV } from "util/sorts"; import { FormatDuration, FormatTime, MillisToSince } from "util/time"; import IndexCell from "../cells/IndexCell"; @@ -87,7 +88,3 @@ export default function GenericSessionTable({ /> ); } - -function GetPBs(scoreInfo: SessionScoreInfo[]) { - return scoreInfo.map(e => e.isNewScore === true || e.lampDelta > 0 || e.scoreDelta > 0); -} diff --git a/client/src/components/user/UGPTHeader.tsx b/client/src/components/user/UGPTHeader.tsx index 330abc72a..7fe3a07ec 100644 --- a/client/src/components/user/UGPTHeader.tsx +++ b/client/src/components/user/UGPTHeader.tsx @@ -1,13 +1,15 @@ import MiniTable from "components/tables/components/MiniTable"; import Divider from "components/util/Divider"; import React from "react"; -import { Game, GetGameConfig, PublicUserDocument } from "tachi-common"; +import { Game, GetGameConfig, GetGamePTConfig, PublicUserDocument } from "tachi-common"; import { UGPTStatsReturn } from "types/api-returns"; import { Playtype } from "types/tachi"; import { MillisToSince } from "util/time"; import ProfilePicture from "./ProfilePicture"; import Navbar from "components/nav/Navbar"; import NavItem from "components/nav/NavItem"; +import ClassBadge from "components/game/ClassBadge"; +import { UppercaseFirst } from "util/misc"; export default function UGPTHeader({ reqUser, @@ -21,6 +23,7 @@ export default function UGPTHeader({ stats: UGPTStatsReturn; }) { const gameConfig = GetGameConfig(game); + const gptConfig = GetGamePTConfig(game, playtype); return (
@@ -77,18 +80,31 @@ export default function UGPTHeader({ colSpan={2} > <> + {gptConfig.supportedClasses.map(k => ( +
+ + + + ))} {Object.entries(stats.gameStats.ratings).map(([k, v]) => ( ))} - {Object.entries(stats.gameStats.classes).map(([k, v]) => ( - - - - - ))} diff --git a/client/src/components/util/AsyncLoader.tsx b/client/src/components/util/AsyncLoader.tsx index c29f99948..e574ff023 100644 --- a/client/src/components/util/AsyncLoader.tsx +++ b/client/src/components/util/AsyncLoader.tsx @@ -9,7 +9,7 @@ export default function AsyncLoader({ }: { promiseFn?: () => Promise; promise?: Promise; - children: (data: T) => JSX.Element; + children: (data: T) => JSX.Element | null | string | (JSX.Element | string)[]; }) { const Component = children; return ( diff --git a/client/src/types/api-returns.ts b/client/src/types/api-returns.ts index eb53c0cf1..c67c99e22 100644 --- a/client/src/types/api-returns.ts +++ b/client/src/types/api-returns.ts @@ -10,6 +10,10 @@ import { ShowcaseStatChart, ShowcaseStatFolder, UserGameStatsSnapshot, + SessionDocument, + IDStringToGame, + ChartDocument, + SongDocument, } from "tachi-common"; export interface UGPTStatsReturn { @@ -55,3 +59,11 @@ export type UGPTPreferenceStatsReturn = }; export type UGPTHistory = Omit[]; + +export interface SessionReturns { + session: SessionDocument; + scores: ScoreDocument[]; + songs: SongDocument[]; + charts: ChartDocument[]; + user: PublicUserDocument; +} diff --git a/client/src/util/api.ts b/client/src/util/api.ts index d58ab21b1..a5a9cda51 100644 --- a/client/src/util/api.ts +++ b/client/src/util/api.ts @@ -45,6 +45,12 @@ export async function APIFetchV1( const rj = await res.json(); + if (!rj.success) { + console.error(rj); + } else { + console.log(rj.description); + } + if (!rj.success && displayFailure) { toast.error(rj.description, { duration: 10_000 }); } diff --git a/client/src/util/constants/time.ts b/client/src/util/constants/time.ts new file mode 100644 index 000000000..e2581e14c --- /dev/null +++ b/client/src/util/constants/time.ts @@ -0,0 +1,5 @@ +export const ONE_SECOND = 1000; +export const ONE_MINUTE = ONE_SECOND * 60; +export const ONE_HOUR = ONE_MINUTE * 60; +export const ONE_DAY = ONE_HOUR * 24; +export const ONE_WEEK = ONE_DAY * 7; diff --git a/client/src/util/data.ts b/client/src/util/data.ts new file mode 100644 index 000000000..5a4721ffc --- /dev/null +++ b/client/src/util/data.ts @@ -0,0 +1,32 @@ +import { + ChartDocument, + Game, + IDStrings, + integer, + SessionScoreInfo, + SongDocument, +} from "tachi-common"; + +export function GetPBs(scoreInfo: SessionScoreInfo[]) { + return scoreInfo.map(e => e.isNewScore === true || e.lampDelta > 0 || e.scoreDelta > 0); +} + +export function CreateSongMap(songs: SongDocument[]) { + const songMap = new Map>(); + + for (const song of songs) { + songMap.set(song.id, song); + } + + return songMap; +} + +export function CreateChartMap(charts: ChartDocument[]) { + const chartMap = new Map>(); + + for (const chart of charts) { + chartMap.set(chart.chartID, chart); + } + + return chartMap; +}
{UppercaseFirst(k)} + {stats.gameStats.classes[k] ? ( + + ) : ( + "No Data" + )} +
{k} {v.toFixed(2)}
{k}{v}