diff --git a/client/package.json b/client/package.json index f191f66ff..6e996592d 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "tachi-client", - "version": "2.0.1", + "version": "2.0.2", "private": true, "scripts": { "start": "node scripts/start.js -o", @@ -82,7 +82,7 @@ "resolve": "1.12.0", "resolve-url-loader": "3.1.0", "socicon": "3.0.5", - "tachi-common": "0.3.10" + "tachi-common": "0.3.19" }, "devDependencies": { "@types/humanize-duration": "3.25.1", diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 4a9356f88..24bf998a4 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -84,7 +84,7 @@ specifiers: serve: 11.2.0 socicon: 3.0.5 style-loader: 1.0.0 - tachi-common: 0.3.10 + tachi-common: 0.3.19 terser-webpack-plugin: 1.4.1 ts-pnp: 1.1.4 typescript: 4.3.5 @@ -156,7 +156,7 @@ dependencies: resolve: 1.12.0 resolve-url-loader: 3.1.0 socicon: 3.0.5 - tachi-common: 0.3.10 + tachi-common: 0.3.19 devDependencies: '@types/humanize-duration': 3.25.1 @@ -11241,8 +11241,8 @@ packages: strip-ansi: 6.0.0 dev: true - /tachi-common/0.3.10: - resolution: {integrity: sha512-OiEnRz8b7ix+m/W5bFwaN+0fgupmbYHKhsn4/IaFb8f2rCEHY/i3t8TGuYAx212Uo2hOsDSX5/yxVOrgcxZ1AA==} + /tachi-common/0.3.19: + resolution: {integrity: sha512-N3/n9emh2X9pA+3jQ1UfkLX9V8hk/oHGE1GPlkReRmyIR9gRAxhf3uZCiorPqibSkPzx9LrGLJcO52FR5NQ8oA==} dependencies: monk: 7.3.4 transitivePeerDependencies: diff --git a/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx b/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx index a41ab1916..d9377ea30 100644 --- a/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx +++ b/client/src/app/pages/dashboard/games/_game/_playtype/GPTLeaderboardsPage.tsx @@ -79,7 +79,7 @@ function ProfileLeaderboard({ game, playtype }: GamePT) { ) : null; const { data, isLoading, error } = useApiQuery( - `/games/${game}/${playtype}/leaderboard?alg=${alg}` + `/games/${game}/${playtype}/leaderboard?alg=${alg}&limit=500` ); if (error) { diff --git a/client/src/app/pages/dashboard/games/_game/_playtype/GPTMainPage.tsx b/client/src/app/pages/dashboard/games/_game/_playtype/GPTMainPage.tsx index 65cdf653b..e0a9d8cef 100644 --- a/client/src/app/pages/dashboard/games/_game/_playtype/GPTMainPage.tsx +++ b/client/src/app/pages/dashboard/games/_game/_playtype/GPTMainPage.tsx @@ -175,7 +175,7 @@ function AchievedClassRow({ playtype={playtype} classValue={classInfo.classValue} /> - {classInfo.classOldValue && ( + {classInfo.classOldValue !== null && ( <> {" "} (Raised from{" "} diff --git a/client/src/app/pages/dashboard/users/UserSettingsPage.tsx b/client/src/app/pages/dashboard/users/UserSettingsPage.tsx index 2ca2bcb98..768da0ad4 100644 --- a/client/src/app/pages/dashboard/users/UserSettingsPage.tsx +++ b/client/src/app/pages/dashboard/users/UserSettingsPage.tsx @@ -172,6 +172,7 @@ function PreferencesForm({ reqUser }: { reqUser: PublicUserDocument }) { invisible: settings?.preferences.invisible ?? false, contentiousContent: settings?.preferences.contentiousContent ?? false, advancedMode: settings?.preferences.advancedMode ?? false, + deletableScores: settings?.preferences.deletableScores ?? false, }, onSubmit: async values => { const res = await APIFetchV1( @@ -224,6 +225,19 @@ function PreferencesForm({ reqUser }: { reqUser: PublicUserDocument }) { /> Hide your last seen status. */} + + + + Enables the option to delete scores. Turn this off if the thought of deleting a + score gives you anxiety. + + (); for (const u of stats.users) { @@ -131,7 +134,13 @@ function LeaderboardsPageContent({ {userMap.get(s.userID)?.username} - {(s.ratings[alg] ?? 0).toFixed(2)} + + {s.ratings[alg] + ? gptConfig.profileRatingAlgFormatters[alg] + ? gptConfig.profileRatingAlgFormatters[alg]!(s.ratings[alg]!) + : s.ratings[alg]!.toFixed(2) + : "No Data."} + {/* temp */} {Object.entries(s.classes).length 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 13bcbdc82..20b2ef33d 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 @@ -10,6 +10,7 @@ import useQueryString from "components/util/useQueryString"; import { UGPTSettingsContext } from "context/UGPTSettingsContext"; import deepmerge from "deepmerge"; import { useFormik } from "formik"; +import { TachiConfig } from "lib/config"; import React, { useContext, useEffect, useState } from "react"; import { Button, Form } from "react-bootstrap"; import { @@ -83,6 +84,7 @@ function PreferencesForm({ reqUser, game, playtype }: Props) { preferredSessionAlg: settings!.preferences.preferredSessionAlg || gptConfig.defaultSessionRatingAlg, gameSpecific: settings!.preferences.gameSpecific, + scoreBucket: settings!.preferences.scoreBucket ?? gptConfig.scoreBucket, }, onSubmit: async values => { const rj = await APIFetchV1( @@ -157,6 +159,24 @@ function PreferencesForm({ reqUser, game, playtype }: Props) { used for things like leaderboards. + + Preferred Score Info + + + + + + What should {TachiConfig.name} prefer to show you about scores? +
+ Note: This will only affect defaults, such as what graph is shown in the folder + breakdown. You can still view all the same stats! +
+
{game === "iidx" && ( { - await APIFetchV1( + const r = await APIFetchV1( `/users/${reqUser.id}/games/${game}/${playtype}/showcase`, { method: "PUT", @@ -198,6 +218,10 @@ function ShowcaseForm({ reqUser, game, playtype }: Props) { true, true ); + + if (r.success) { + setSettings(r.body); + } }; const [isFirstPaint, setIsFirstPaint] = useState(true); diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderSelectPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderSelectPage.tsx index 8fc323802..2df7972f8 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderSelectPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderSelectPage.tsx @@ -1,5 +1,6 @@ import { ResponsiveBar } from "@nivo/bar"; import { BarChartTooltip } from "components/charts/ChartTooltip"; +import DistributionTable from "components/game/folder/FolderDistributionTable"; import Card from "components/layout/page/Card"; import MiniTable from "components/tables/components/MiniTable"; import DebounceSearch from "components/util/DebounceSearch"; @@ -10,6 +11,7 @@ import Loading from "components/util/Loading"; import Muted from "components/util/Muted"; import useApiQuery from "components/util/query/useApiQuery"; import SelectButton from "components/util/SelectButton"; +import { useBucket } from "components/util/useBucket"; import useUGPTBase from "components/util/useUGPTBase"; import { UserContext } from "context/UserContext"; import React, { useContext, useMemo, useState } from "react"; @@ -89,7 +91,9 @@ export function FolderInfoComponent({ }: Props & { folder: FolderDocument; folderStats: FolderStatsInfo }) { const gptConfig = GetGamePTConfig(game, playtype); - const [elements, setElements] = useState<"grade" | "lamp">(gptConfig.scoreBucket); + const scoreBucket = useBucket(game, playtype); + + const [elements, setElements] = useState<"grade" | "lamp">(scoreBucket); const base = useUGPTBase({ reqUser, game, playtype }); @@ -160,94 +164,3 @@ export function FolderInfoComponent({ ); } - -function DistributionTable({ - keys, - values, - colours, - max, -}: { - keys: T[]; - values: Record; - colours: Record; - max: integer; -}) { - const cumulativeValues: Record = {} as Record; - - let total = 0; - for (const k of keys) { - total += values[k] ?? 0; - cumulativeValues[k] = total; - } - - return ( - - {keys.map((k, i) => ( - - {k} - - {values[k] ?? 0} ({cumulativeValues[k]}) - - {i === 0 && ( - - )} - - ))} - - ); -} - -function FolderThermometer({ - keys, - values, - max, - colours, -}: { - keys: T[]; - values: Record; - colours: Record; - max: integer; -}) { - return ( - - ChangeOpacity(colours[k.id], 0.5)} - // @ts-expect-error Keys are appended with "." for some reason. - borderColor={k => colours[k.data.id]} - labelTextColor="black" - labelSkipHeight={12} - maxValue={max} - padding={0.33} - borderWidth={1} - valueScale={{ type: "linear" }} - axisRight={{ - tickSize: 5, - tickPadding: 5, - tickValues: StepFromToMax(max), - }} - margin={{ right: 50, bottom: 10, top: 20 }} - tooltip={d => ( - ( -
- {d.label} - {d.value} ({PercentFrom(d.value, max)}) -
- )} - /> - )} - motionConfig="stiff" - /> - - ); -} diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.tsx index ddbec68f7..af78c1d9f 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.tsx @@ -10,6 +10,7 @@ import Loading from "components/util/Loading"; import Muted from "components/util/Muted"; import useApiQuery from "components/util/query/useApiQuery"; import SelectButton from "components/util/SelectButton"; +import { useBucket } from "components/util/useBucket"; import { UserContext } from "context/UserContext"; import React, { useContext, useEffect, useMemo, useState } from "react"; import { Form, InputGroup } from "react-bootstrap"; @@ -218,10 +219,9 @@ function TableBarChart({ dataMap: Map; }) { const gptConfig = GetGamePTConfig(table.game, table.playtype); + const bucket = useBucket(table.game, table.playtype); - const [mode, setMode] = useState<"grades" | "lamps">( - gptConfig.scoreBucket === "grade" ? "grades" : "lamps" - ); + const [mode, setMode] = useState<"grades" | "lamps">(bucket === "grade" ? "grades" : "lamps"); const colours = useMemo(() => { if (mode === "grades") { @@ -254,7 +254,10 @@ function TableBarChart({ } arr.push({ - folder: data.folder.title, + folder: + data.folder.title.length > 10 + ? `${data.folder.title.slice(0, 10)}...` + : data.folder.title, ...realData, }); } @@ -327,7 +330,11 @@ function OverviewBarChart({ }) { const longestTitle = useMemo( () => - dataset.map((e: any) => e.folder).sort(NumericSOV(x => x.length, true))[0] ?? 0, + Math.min( + dataset.map((e: any) => e.folder).sort(NumericSOV(x => x.length, true))[0] + ?.length ?? 0, + 10 + ), [dataset] ); @@ -360,7 +367,7 @@ function OverviewBarChart({ minValue={0} maxValue={100} valueFormat={v => `${v.toFixed(2)}%`} - margin={{ left: 10 + longestTitle.length * 7, bottom: 50, top: 20, right: 20 }} + margin={{ left: 10 + longestTitle * 8, bottom: 50, top: 20, right: 20 }} layout="horizontal" axisBottom={{ tickSize: 5, diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/SpecificFolderPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/SpecificFolderPage.tsx index d1a8e2f9a..5b5585f5b 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/SpecificFolderPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/SpecificFolderPage.tsx @@ -1,5 +1,6 @@ import { ResponsiveBar } from "@nivo/bar"; import { BarChartTooltip } from "components/charts/ChartTooltip"; +import FolderInfoHeader from "components/game/folder/FolderInfoHeader"; import QuickTooltip from "components/layout/misc/QuickTooltip"; import Card from "components/layout/page/Card"; import FolderTable from "components/tables/folders/FolderTable"; @@ -10,6 +11,7 @@ import Loading from "components/util/Loading"; import Muted from "components/util/Muted"; import useApiQuery from "components/util/query/useApiQuery"; import SelectButton from "components/util/SelectButton"; +import { useBucket } from "components/util/useBucket"; import { useFormik } from "formik"; import { nanoid } from "nanoid"; import React, { useEffect, useMemo, useState } from "react"; @@ -93,7 +95,7 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) { return ( -
- -
{folderInfoHeader}
@@ -658,107 +657,3 @@ function FolderDatasetToTierlistInfo( return tierlistInfo.sort(NumericSOV(x => x.data.value, true)); } - -function FolderInfoHeader({ game, playtype, reqUser, folderDataset, data }: InfoProps) { - return ( - - - - ); -} - -function ScoreDistributionChart({ game, playtype, folderDataset }: InfoProps) { - const dataMap = CreateChartIDMap(folderDataset); - const gptConfig = GetGamePTConfig(game, playtype); - - const dataset = []; - - const expScale = GetGradeChartExpScale(game); - - const expFn = ComposeExpFn(expScale); - const invExpFn = ComposeInverseExpFn(expScale); - - for (const data of folderDataset) { - const value = data.__related.pb ? expFn(data.__related.pb.scoreData.percent) : 0; - dataset.push({ - chartID: data.chartID, - expValue: expFn(value), - value, - grade: data.__related.pb?.scoreData.grade, - }); - } - - dataset.sort(NumericSOV(x => x.expValue)); - - return ( -
- ( - { - const data = dataMap.get(d.indexValue as string)!; - - return ( -
- {data.__related.song.title} -
- {data.__related.pb?.scoreData.percent.toFixed(2)}% -
- ); - }} - /> - )} - key={"value"} - colors={k => { - if (!k.data.grade) { - return "black"; - } - // @ts-expect-error temp - return ChangeOpacity(gptConfig.gradeColours[k.data.grade], 0.5); - }} - // @ts-expect-error temp - borderColor={k => gptConfig.gradeColours[k.data.grade]} - borderWidth={1} - padding={0.2} - // @ts-expect-error temp - data={dataset} - minValue={0} - maxValue={expFn(100)} - margin={{ left: 50, top: 20, bottom: 20 }} - valueFormat={e => `${invExpFn(e).toFixed(2)}%`} - axisLeft={{ - tickValues: gptConfig.gradeBoundaries.map(e => (e === 0 ? 0 : expFn(e))), - format: x => { - let nearest; - - const lgv = invExpFn(x); - - for (const [i, gradeBnd] of gptConfig.gradeBoundaries.entries()) { - if (Math.abs(gradeBnd - lgv) < 0.00005) { - nearest = i; - break; - } - } - - if (nearest === undefined) { - return null; - } - - return gptConfig.grades[nearest]; - }, - }} - axisBottom={null} - {...DEFAULT_BAR_PROPS} - labelSkipWidth={40} - /> -
- ); -} diff --git a/client/src/app/routes/GameRoutes.tsx b/client/src/app/routes/GameRoutes.tsx index d53ff24d9..6882c7f6c 100644 --- a/client/src/app/routes/GameRoutes.tsx +++ b/client/src/app/routes/GameRoutes.tsx @@ -164,6 +164,7 @@ function SongChartRoutes({ game, playtype }: GamePT) { useEffect(() => { setActiveChart(null); }, [game, playtype]); + if (error) { return ; } @@ -172,6 +173,14 @@ function SongChartRoutes({ game, playtype }: GamePT) { return ; } + if (data.charts.every(c => c.playtype !== playtype)) { + return ( + + ); + } + return ( <> ({ + keys, + values, + colours, + max, +}: { + keys: T[]; + values: Record; + colours: Record; + max: integer; +}) { + const cumulativeValues: Record = {} as Record; + + let total = 0; + for (const k of keys) { + total += values[k] ?? 0; + cumulativeValues[k] = total; + } + + return ( + + {keys.map((k, i) => ( + + {k} + + {values[k] ?? 0} ({cumulativeValues[k]}) + + {i === 0 && ( + + )} + + ))} + + ); +} + +function FolderThermometer({ + keys, + values, + max, + colours, +}: { + keys: T[]; + values: Record; + colours: Record; + max: integer; +}) { + return ( + + ChangeOpacity(colours[k.id], 0.5)} + // @ts-expect-error Keys are appended with "." for some reason. + borderColor={k => colours[k.data.id]} + labelTextColor="black" + labelSkipHeight={12} + maxValue={max} + padding={0.33} + borderWidth={1} + valueScale={{ type: "linear" }} + axisRight={{ + tickSize: 5, + tickPadding: 5, + tickValues: StepFromToMax(max), + }} + margin={{ right: 50, bottom: 10, top: 20 }} + tooltip={d => ( + ( +
+ {d.label} + {d.value} ({PercentFrom(d.value, max)}) +
+ )} + /> + )} + motionConfig="stiff" + /> + + ); +} diff --git a/client/src/components/game/folder/FolderInfoHeader.tsx b/client/src/components/game/folder/FolderInfoHeader.tsx new file mode 100644 index 000000000..bd1bd1bbd --- /dev/null +++ b/client/src/components/game/folder/FolderInfoHeader.tsx @@ -0,0 +1,74 @@ +import Card from "components/layout/page/Card"; +import Divider from "components/util/Divider"; +import Icon from "components/util/Icon"; +import SelectButton from "components/util/SelectButton"; +import { useBucket } from "components/util/useBucket"; +import React, { useState } from "react"; +import { PublicUserDocument } from "tachi-common"; +import { GamePT } from "types/react"; +import { FolderDataset } from "types/tables"; +import FolderMinimap from "./FolderMinimap"; +import FolderScoreDistributionChart from "./FolderScoreDistributionChart"; + +export default function FolderInfoHeader({ + game, + playtype, + reqUser, + folderDataset, + folderTitle, +}: { + reqUser: PublicUserDocument; + folderDataset: FolderDataset; + folderTitle: string; +} & GamePT) { + const scoreBucket = useBucket(game, playtype); + const [view, setView] = useState<"grade" | "lamp">(scoreBucket); + const [breakdownInfo, setBreakdownInfo] = useState<"graph" | "switchboard">("switchboard"); + + return ( + +
+
+ + + Score Info + + + + Lamp Info + +
+
+
+ +
+
+ +
+ + {breakdownInfo === "graph" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/client/src/components/game/folder/FolderMinimap.tsx b/client/src/components/game/folder/FolderMinimap.tsx new file mode 100644 index 000000000..7e2e1de76 --- /dev/null +++ b/client/src/components/game/folder/FolderMinimap.tsx @@ -0,0 +1,236 @@ +import QuickTooltip from "components/layout/misc/QuickTooltip"; +import ApiError from "components/util/ApiError"; +import Divider from "components/util/Divider"; +import Loading from "components/util/Loading"; +import useApiQuery from "components/util/query/useApiQuery"; +import React, { useEffect, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; +import { Link } from "react-router-dom"; +import { + ChartDocument, + FormatChart, + Game, + GamePTConfig, + GetGamePTConfig, + IIDX_LAMPS, + PublicUserDocument, + SessionDocument, +} from "tachi-common"; +import { GamePT } from "types/react"; +import { FolderDataset } from "types/tables"; +import { ChangeOpacity } from "util/color-opacity"; +import { CreateChartLink } from "util/data"; +import { NumericSOV } from "util/sorts"; + +type Props = { + folderDataset: FolderDataset; + reqUser: PublicUserDocument; + view: "grade" | "lamp"; +} & GamePT; + +export default function FolderMinimap(props: Props) { + const { data, isLoading, error } = useApiQuery( + `/users/${props.reqUser.id}/games/${props.game}/${props.playtype}/sessions/last` + ); + + const session = useMemo(() => { + if (error && error.statusCode === 404) { + return null; + } else if (data) { + return data; + } + + return null; + }, [data, error]); + + if (error && error.statusCode !== 404) { + return ; + } + + if (isLoading || !data) { + return ; + } + + return ( + <> +
+ +
+
+ Sadly, Switchboard view doesn't work very well on mobile at the moment. +
+ + ); +} + +function FolderMinimapMain({ + game, + playtype, + folderDataset, + view, + recentSession, +}: Props & { recentSession: SessionDocument | null }) { + const gptConfig = GetGamePTConfig(game, playtype); + + const [sortAlg, setSortAlg] = useState(view === "grade" ? "score" : "lamp"); + + useEffect(() => { + if (view === "grade") { + setSortAlg("score"); + } else { + setSortAlg("lamp"); + } + }, [view]); + + const getterFn = useMemo<(f: FolderDataset[0]) => number | undefined>(() => { + if (sortAlg === "score") { + return c => c.__related.pb?.scoreData.percent; + } else if (sortAlg === "lamp") { + return c => c.__related.pb?.scoreData.lampIndex; + } else if (sortAlg.startsWith("tierlist:")) { + const v = sortAlg.split("tierlist:")[1]; + + return c => c.tierlistInfo[v as keyof ChartDocument["tierlistInfo"]]?.value; + } + + return () => 0; + }, [sortAlg]); + + const sortedDataset = useMemo( + () => folderDataset.slice(0).sort(NumericSOV(a => getterFn(a) ?? -Infinity, true)), + [getterFn, folderDataset, sortAlg] + ); + + const recentlyTouched = useMemo(() => { + if (!recentSession) { + return []; + } + + return recentSession.scoreInfo + .filter(e => e.isNewScore || e.gradeDelta > 0 || e.lampDelta > 0) + .map(e => e.scoreID); + }, [recentSession]); + + return ( +
+
+
+ {sortedDataset.map(d => ( + + d.__related.pb!.composedFrom.scorePB === scoreID || + d.__related.pb!.composedFrom.lampPB === scoreID + )) ?? + false + } + /> + ))} +
+ + + Sorting charts on: + setSortAlg(e.target.value)} + > + {view === "grade" && } + {view === "lamp" && } + {gptConfig.tierlists.map(e => ( + + ))} + + +
+
+ ); +} + +function MinimapElement({ + data, + gptConfig, + view, + game, + wasRecent, +}: { + data: FolderDataset[0]; + view: "grade" | "lamp"; + gptConfig: GamePTConfig; + game: Game; + wasRecent: boolean; +}) { + let icon = "level-up-alt"; + + // if this user recently cleared mare nectaris + // tell them to fuck off + if ( + data.chartID === "924bf011fdd8334b609b02e382123f9f5440d16d" && + data.__related.pb && + data.__related.pb?.scoreData.lampIndex >= IIDX_LAMPS.EASY_CLEAR + ) { + icon = "hand-middle-finger"; + } + + const colour = useMemo(() => { + if (!data.__related.pb) { + return null; + } + + // @ts-expect-error unhinged dynamic access (i dont care) + return gptConfig[`${view}Colours`][data.__related.pb.scoreData[view]]; + }, [data.__related.pb, gptConfig, view]); + + return ( + + {FormatChart(game, data.__related.song, data)} + + {wasRecent && ( + <> + You raised this recently! +
+ + )} + {data.__related.pb?.scoreData.lamp ?? "Not Played"} + {data.__related.pb && ( + <> +
+ {data.__related.pb.scoreData.grade} +
+ + {data.__related.pb.scoreData.score} ( + {data.__related.pb.scoreData.percent.toFixed(2)}%) + + + )} +
+ } + > + +
+ {wasRecent && ( + + )} +
+ + + ); +} diff --git a/client/src/components/game/folder/FolderScoreDistributionChart.tsx b/client/src/components/game/folder/FolderScoreDistributionChart.tsx new file mode 100644 index 000000000..6df7a4003 --- /dev/null +++ b/client/src/components/game/folder/FolderScoreDistributionChart.tsx @@ -0,0 +1,157 @@ +import { ResponsiveBar } from "@nivo/bar"; +import { BarChartTooltip } from "components/charts/ChartTooltip"; +import React, { useMemo } from "react"; +import { GetGamePTConfig } from "tachi-common"; +import { GamePT } from "types/react"; +import { FolderDataset } from "types/tables"; +import { DEFAULT_BAR_PROPS } from "util/charts"; +import { ChangeOpacity } from "util/color-opacity"; +import { CreateChartIDMap } from "util/data"; +import { ComposeExpFn, ComposeInverseExpFn, CountElements, Reverse } from "util/misc"; +import { GetGradeChartExpScale } from "util/scales"; +import { NumericSOV } from "util/sorts"; +import FolderDistributionTable from "./FolderDistributionTable"; + +type Props = { + folderDataset: FolderDataset; + view: "grade" | "lamp"; +} & GamePT; + +export default function FolderScoreDistributionChart({ + game, + playtype, + folderDataset, + view, +}: Props) { + const gptConfig = GetGamePTConfig(game, playtype); + + const values = useMemo(() => { + if (view === "grade") { + return CountElements(folderDataset, x => x.__related.pb?.scoreData.grade ?? null); + } + + return CountElements(folderDataset, x => x.__related.pb?.scoreData.lamp ?? null); + }, [folderDataset, view]); + + return ( +
+
+ {view === "grade" ? ( + + ) : ( + + )} +
+ {/*
+ +
*/} +
+ ); +} + +function ScoreDistributionChart({ game, folderDataset, playtype, view }: Props) { + if (view === "grade") { + return ; + } + + return <>LAMP TODO; +} + +function ScoreBarChart({ game, playtype, folderDataset }: Omit) { + const dataMap = CreateChartIDMap(folderDataset); + const gptConfig = GetGamePTConfig(game, playtype); + + const expScale = GetGradeChartExpScale(game); + const expFn = ComposeExpFn(expScale); + const invExpFn = ComposeInverseExpFn(expScale); + + const dataset = []; + + for (const data of folderDataset) { + const value = data.__related.pb ? expFn(data.__related.pb.scoreData.percent) : 0; + dataset.push({ + chartID: data.chartID, + expValue: expFn(value), + value, + grade: data.__related.pb?.scoreData.grade, + }); + } + + dataset.sort(NumericSOV(x => x.expValue)); + + return ( +
+ ( + { + const data = dataMap.get(d.indexValue as string)!; + + return ( +
+ {data.__related.song.title} +
+ {data.__related.pb?.scoreData.percent.toFixed(2)}% +
+ ); + }} + /> + )} + key={"value"} + colors={k => { + if (!k.data.grade) { + return "black"; + } + // @ts-expect-error temp + return ChangeOpacity(gptConfig.gradeColours[k.data.grade], 0.5); + }} + // @ts-expect-error temp + borderColor={k => gptConfig.gradeColours[k.data.grade]} + borderWidth={1} + padding={0.2} + // @ts-expect-error temp + data={dataset} + minValue={0} + maxValue={expFn(100)} + margin={{ left: 50, top: 20, bottom: 20 }} + valueFormat={e => `${invExpFn(e).toFixed(2)}%`} + axisLeft={{ + tickValues: gptConfig.gradeBoundaries.map(e => (e === 0 ? 0 : expFn(e))), + format: x => { + let nearest; + + const lgv = invExpFn(x); + + for (const [i, gradeBnd] of gptConfig.gradeBoundaries.entries()) { + if (Math.abs(gradeBnd - lgv) < 0.00005) { + nearest = i; + break; + } + } + + if (nearest === undefined) { + return null; + } + + return gptConfig.grades[nearest]; + }, + }} + axisBottom={null} + {...DEFAULT_BAR_PROPS} + labelSkipWidth={40} + /> +
+ ); +} diff --git a/client/src/components/tables/dropdowns/GenericScoreDropdown.tsx b/client/src/components/tables/dropdowns/GenericScoreDropdown.tsx index 67d31a6c4..aa28e0f32 100644 --- a/client/src/components/tables/dropdowns/GenericScoreDropdown.tsx +++ b/client/src/components/tables/dropdowns/GenericScoreDropdown.tsx @@ -22,6 +22,7 @@ import useApiQuery from "components/util/query/useApiQuery"; import HasDevModeOn from "components/util/HasDevModeOn"; import PBCompare from "./components/PBCompare"; import { UserSettingsContext } from "context/UserSettingsContext"; +import ManageScore from "./components/ManageScore"; export interface ScoreState { highlight: boolean; @@ -46,7 +47,7 @@ export default function GenericScoreDropdown({ user: PublicUserDocument; chart: ChartDocument; scoreState: ScoreState; - defaultView?: "vsPB" | "moreInfo" | "history" | "debug"; + defaultView?: "vsPB" | "moreInfo" | "history" | "debug" | "manage"; thisScore: ScoreDocument; DocComponent?: (props: { score: ScoreDocument | PBScoreDocument; @@ -55,6 +56,8 @@ export default function GenericScoreDropdown({ }) => JSX.Element; } & GamePT) { const [view, setView] = useState(defaultView); + const { user: currentUser } = useContext(UserContext); + const { settings } = useContext(UserSettingsContext); const { isLoading, error, data } = useApiQuery>( `/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}?getComposition=true` @@ -94,6 +97,8 @@ export default function GenericScoreDropdown({ body = ; } else if (view === "vsPB") { body = ; + } else if (view === "manage") { + body = ; } return ( @@ -112,6 +117,12 @@ export default function GenericScoreDropdown({ Play History + {currentUser?.id === user.id && settings?.preferences.deletableScores && ( + + + Manage Score + + )} diff --git a/client/src/components/tables/dropdowns/components/GenericScoreContentDropdown.tsx b/client/src/components/tables/dropdowns/components/GenericScoreContentDropdown.tsx index 595420917..7470ebb0d 100644 --- a/client/src/components/tables/dropdowns/components/GenericScoreContentDropdown.tsx +++ b/client/src/components/tables/dropdowns/components/GenericScoreContentDropdown.tsx @@ -1,38 +1,33 @@ -import DeltaCell from "components/tables/cells/DeltaCell"; -import LampCell from "components/tables/cells/LampCell"; -import ScoreCell from "components/tables/cells/ScoreCell"; import TimestampCell from "components/tables/cells/TimestampCell"; -import ExternalLink from "components/util/ExternalLink"; +import ScoreCoreCells from "components/tables/game-core-cells/ScoreCoreCells"; +import useScoreRatingAlg from "components/util/useScoreRatingAlg"; import React, { useEffect, useState } from "react"; import { PBScoreDocument, ScoreDocument } from "tachi-common"; import { UGPTChartPBComposition } from "types/api-returns"; import { SetState } from "types/react"; import { IsScore } from "util/asserts"; +import { UppercaseFirst } from "util/misc"; import CommentContainer from "./CommentContainer"; import JudgementTable from "./JudgementTable"; import PBNote from "./PBNote"; import ScoreEditButtons from "./ScoreEditButtons"; export function ScoreInfo({ score }: { score: ScoreDocument | PBScoreDocument }) { + const rating = useScoreRatingAlg(score.game, score.playtype); + return (
- + + + - - - + {/* @ts-expect-error yeah we know service doesnt necessarily exist */} diff --git a/client/src/components/tables/dropdowns/components/ManageScore.tsx b/client/src/components/tables/dropdowns/components/ManageScore.tsx new file mode 100644 index 000000000..c1271743d --- /dev/null +++ b/client/src/components/tables/dropdowns/components/ManageScore.tsx @@ -0,0 +1,45 @@ +import React, { useMemo, useReducer, useState } from "react"; +import { ScoreDocument } from "tachi-common"; +import { APIFetchV1 } from "util/api"; +import { DelayedPageReload } from "util/misc"; + +export default function ManageScore({ score }: { score: ScoreDocument }) { + const [warn, upgWarn] = useReducer(r => r + 1, 0); + const message = useMemo(() => { + if (warn === 0) { + return "Delete Score (Requires Further Confirmation)"; + } else if (warn === 1) { + return "Are you absolutely sure? This score will be gone. Permanently."; + } else if (warn === 2) { + return "I'm serious. You will lose this score. It will be gone. Are you REALLY sure you want to do this?"; + } else if (warn === 3) { + return "OK. Click me one last time, then."; + } + + return "lol unknown state"; + }, [warn]); + + return ( +
+
{ + if (warn < 3) { + upgWarn(); + } else { + APIFetchV1( + `/scores/${score.scoreID}`, + { + method: "DELETE", + }, + true, + true + ).then(() => DelayedPageReload()); + } + }} + > + {message} +
+
+ ); +} diff --git a/client/src/components/tables/dropdowns/components/ScoreEditButtons.tsx b/client/src/components/tables/dropdowns/components/ScoreEditButtons.tsx index 4308e0116..5d24b2f8e 100644 --- a/client/src/components/tables/dropdowns/components/ScoreEditButtons.tsx +++ b/client/src/components/tables/dropdowns/components/ScoreEditButtons.tsx @@ -41,7 +41,7 @@ export default function ScoreEditButtons({ onClick={() => setShow(true)} > - Edit + Comment @@ -49,7 +49,7 @@ export default function ScoreEditButtons({ )} diff --git a/client/src/components/user/UGPTStatShowcase.tsx b/client/src/components/user/UGPTStatShowcase.tsx index 454972672..beb98cd30 100644 --- a/client/src/components/user/UGPTStatShowcase.tsx +++ b/client/src/components/user/UGPTStatShowcase.tsx @@ -25,7 +25,7 @@ import { UGPTPreferenceStatsReturn } from "types/api-returns"; import { GamePT } from "types/react"; import { Playtype } from "types/tachi"; import { APIFetchV1 } from "util/api"; -import { UppercaseFirst } from "util/misc"; +import { ToPercent, UppercaseFirst } from "util/misc"; import UGPTStatContainer from "./UGPTStatContainer"; import UGPTStatCreator from "./UGPTStatCreator"; @@ -424,15 +424,22 @@ export function StatDisplay({ header={
Folder
} > <> -

{headerStr}

+ +

{headerStr}

+
{UppercaseFirst(stat.property)} >={" "} {FormatPropertyGTE(game, playtype, stat.property, stat.gte)}

{result.value} - {/* @ts-expect-error temp */} - /{result.outOf} + + {/* @ts-expect-error This property definitely exists.*/} + {/* */}/{result.outOf} ({ToPercent(result.value, result.outOf)}) +

{user && user.id !== reqUser.id && ( diff --git a/client/src/components/util/ErrorBoundary.tsx b/client/src/components/util/ErrorBoundary.tsx index 47f317379..cf021923e 100644 --- a/client/src/components/util/ErrorBoundary.tsx +++ b/client/src/components/util/ErrorBoundary.tsx @@ -3,6 +3,7 @@ import CenterLayoutPage from "../layout/CenterLayoutPage"; import DebugContent from "./DebugContent"; import Divider from "./Divider"; import ExternalLink from "./ExternalLink"; +import Muted from "./Muted"; export default class ErrorBoundary extends React.Component { constructor(props: any) { @@ -32,6 +33,11 @@ export default class ErrorBoundary extends React.Component + This error is not recoverable. :/ + + window.location.reload()}> + Attempt Page Reload + Go Home ); diff --git a/client/src/components/util/useBucket.tsx b/client/src/components/util/useBucket.tsx new file mode 100644 index 000000000..d66e23ac1 --- /dev/null +++ b/client/src/components/util/useBucket.tsx @@ -0,0 +1,16 @@ +import { UGPTSettingsContext } from "context/UGPTSettingsContext"; +import { useContext } from "react"; +import { Game, GetGamePTConfig } from "tachi-common"; +import { Playtype } from "types/tachi"; + +export function useBucket(game: Game, playtype: Playtype) { + const { settings } = useContext(UGPTSettingsContext); + + if (!settings?.preferences.scoreBucket) { + const gptConfig = GetGamePTConfig(game, playtype); + + return gptConfig.scoreBucket; + } + + return settings.preferences.scoreBucket; +} diff --git a/client/src/util/color-opacity.ts b/client/src/util/color-opacity.ts index 5fbd6c2ba..2883fbd11 100644 --- a/client/src/util/color-opacity.ts +++ b/client/src/util/color-opacity.ts @@ -3,6 +3,10 @@ * @param opacity A value between 0 and 1. */ export function ChangeOpacity(str: string, opacity: number) { + if (!str) { + throw new Error(`Str given to ChangeOpacity was undefined.`); + } + if (str.startsWith("#")) { if (str.length === 7) { return str + Math.floor(opacity * 255).toString(16); diff --git a/client/src/util/constants/version.ts b/client/src/util/constants/version.ts index 477d5a065..1e1acecb2 100644 --- a/client/src/util/constants/version.ts +++ b/client/src/util/constants/version.ts @@ -1,6 +1,6 @@ const MAJOR = 2; const MINOR = 0; -const PATCH = 1; +const PATCH = 2; // As is with all front-facing zkldi projects, the version names for tachi-client // are from an album I like. In this case, the album is The Cure - Disintegration. diff --git a/client/src/util/misc.ts b/client/src/util/misc.ts index fefb8d715..4b94fdb99 100644 --- a/client/src/util/misc.ts +++ b/client/src/util/misc.ts @@ -197,3 +197,26 @@ export function HistorySafeGoBack(history: ReturnType) { // history.goBack(); // } } +export function ToPercent(n1: number, n2: number) { + return `${((100 * n1) / n2).toFixed(2)}%`; +} + +export function CountElements(data: T[], collector: (element: T) => string | null) { + const counts: Record = {}; + + for (const element of data) { + const key = collector(element); + + if (key === null) { + continue; + } + + if (!counts[key]) { + counts[key] = 1; + } else { + counts[key]++; + } + } + + return counts; +} diff --git a/client/src/util/splashes.ts b/client/src/util/splashes.ts index 2bd06133c..acb68dfbd 100644 --- a/client/src/util/splashes.ts +++ b/client/src/util/splashes.ts @@ -112,6 +112,8 @@ export const contentiousSplashes = [ "...and good faith is when people agree with me", "Anyone else at this party gay?", "I have the transfeminine urge to pee.", + "I HAVE TO LIE IN THE MIDDLE OF THE FLOOR", + "COMPLETELY MOTIONLESS NOT DARING TO BREATHE", ]; export const heySplashes = [
Score InfoScore Info{UppercaseFirst(rating)}Timestamp