diff --git a/client/src/app/pages/dashboard/DashboardPage.tsx b/client/src/app/pages/dashboard/DashboardPage.tsx index 91c645992..eb5ecd715 100644 --- a/client/src/app/pages/dashboard/DashboardPage.tsx +++ b/client/src/app/pages/dashboard/DashboardPage.tsx @@ -54,11 +54,11 @@ function DashboardLoggedIn({ user }: { user: UserDocument }) { {splash}, {user.username}. -
+
- + @@ -156,7 +156,7 @@ function RecentInfo({ user }: { user: UserDocument }) { )} - {data.recentFolders.length !== 0 && ( + {/* {data.recentFolders.length !== 0 && ( <>

Here's some folders you checked out recently.

@@ -174,7 +174,7 @@ function RecentInfo({ user }: { user: UserDocument }) {
- )} + )} */} ); } diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderQuestsPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderQuestsPage.tsx new file mode 100644 index 000000000..647735b05 --- /dev/null +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderQuestsPage.tsx @@ -0,0 +1,78 @@ +import { CreateGoalMap, CreateGoalSubDataset, CreateUserMap } from "util/data"; +import { CreateQuestMap } from "util/misc"; +import SetNewGoalModal from "components/targets/SetNewGoalModal"; +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, { useReducer, useState } from "react"; +import { Button, Col } from "react-bootstrap"; +import { FolderDocument, QuestDocument } from "tachi-common"; +import { GoalsOnChartReturn, GoalsOnFolderReturn } from "types/api-returns"; +import { UGPT } from "types/react"; +import GoalSubTable from "components/tables/goals/GoalSubTable"; + +export default function FolderQuestsPage({ + folder, + game, + playtype, + reqUser, +}: { + folder: FolderDocument; +} & UGPT) { + const [refresh, forceRefresh] = useReducer((x) => x + 1, 0); + + const { data, error } = useApiQuery( + `/users/${reqUser.id}/games/${game}/${playtype}/targets/on-folder/${folder.folderID}`, + undefined, + [refresh] + ); + + const [show, setShow] = useState(false); + + return ( +
+ + + + + {error && } + {data ? ( + + ) : ( + + )} + + {show && ( + + )} +
+ ); +} + +function FolderQuestsInner({ + reqUser, + game, + playtype, + folder, + data, +}: { + data: GoalsOnFolderReturn; + folder: FolderDocument; +} & UGPT) { + const userMap = CreateUserMap([reqUser]); + + return ( + + ); +} 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 9bc97bb55..bf2d54e06 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 @@ -34,11 +34,12 @@ import { UserDocument, ScoreDocument, SongDocument, + Playtype, } from "tachi-common"; import { UGPTFolderReturns } from "types/api-returns"; import { FolderDataset } from "types/tables"; -import { Playtype } from "tachi-common"; import FolderComparePage from "./FolderComparePage"; +import FolderQuestsPage from "./FolderQuestsPage"; interface Props { reqUser: UserDocument; @@ -114,7 +115,7 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
-
+
Normal View @@ -136,6 +137,10 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) { Compare Against User + + + Goals & Quests +
@@ -171,6 +176,14 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) { folder={data.folder} /> + + +
diff --git a/client/src/components/tables/cells/GoalSubTitleCell.tsx b/client/src/components/tables/cells/GoalSubTitleCell.tsx new file mode 100644 index 000000000..3cfd5c249 --- /dev/null +++ b/client/src/components/tables/cells/GoalSubTitleCell.tsx @@ -0,0 +1,52 @@ +import { JoinJSX } from "util/misc"; +import GentleLink from "components/util/GentleLink"; +import Muted from "components/util/Muted"; +import React from "react"; +import { QuestDocument } from "tachi-common"; +import { GamePT } from "types/react"; +import { GoalSubDataset } from "types/tables"; + +export default function GoalSubTitleCell({ + data, + game, + playtype, +}: { + data: GoalSubDataset[0]; +} & GamePT) { + return ( + + + {data.__related.goal.name} + + {data.__related.parentQuests.length > 0 && ( + <> +
+ Part of: + {fmtQuests(data.__related.parentQuests)} + + )} + + ); +} + +function fmtQuests(quests: Array) { + const strs = []; + + for (let i = 0; i < Math.min(quests.length, 5); i++) { + const quest = quests[i]; + + strs.push( + + {quest.name} + + ); + } + + if (quests.length > 5) { + strs.push(And {quests.length - 5} more...); + } + + return JoinJSX(strs, <>, ); +} diff --git a/client/src/components/tables/dropdowns/PBDropdown.tsx b/client/src/components/tables/dropdowns/PBDropdown.tsx index c42886abe..10c00d197 100644 --- a/client/src/components/tables/dropdowns/PBDropdown.tsx +++ b/client/src/components/tables/dropdowns/PBDropdown.tsx @@ -5,9 +5,17 @@ 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 React, { useMemo, useState } from "react"; -import { ChartDocument, IDStrings, integer, PBScoreDocument, ScoreDocument } from "tachi-common"; -import { UGPTChartPBComposition } from "types/api-returns"; +import { UserContext } from "context/UserContext"; +import React, { useContext, useMemo, useReducer, useState } from "react"; +import { + ChartDocument, + IDStrings, + integer, + PBScoreDocument, + ScoreDocument, + SongDocument, +} from "tachi-common"; +import { GoalsOnChartReturn, UGPTChartPBComposition } from "types/api-returns"; import { GamePT } from "types/react"; import DocComponentCreator, { DocumentComponentType, @@ -16,6 +24,7 @@ import DocComponentCreator, { import DropdownStructure from "./components/DropdownStructure"; import PlayHistory from "./components/PlayHistory"; import RivalCompare from "./components/RivalCompare"; +import TargetInfo from "./components/TargetInfo"; import { GPTDropdownSettings } from "./GPTDropdownSettings"; export interface ScoreDropdownProps { @@ -32,12 +41,16 @@ export default function PBDropdown({ scoreState, defaultView = "pb", userID, + song, }: { userID: integer; chart: ChartDocument; + song: SongDocument; scoreState: ScoreState; - defaultView?: "pb" | "scorePB" | "lampPB" | "history" | "debug" | "rivals"; + defaultView?: "pb" | "scorePB" | "lampPB" | "history" | "debug" | "rivals" | "targets"; } & GamePT) { + const { user: currentUser } = useContext(UserContext); + const DocComponent: DocumentComponentType = (props) => DocComponentCreator({ ...props, ...GPTDropdownSettings(game, playtype) }); @@ -51,6 +64,18 @@ export default function PBDropdown({ `/users/${userID}/games/${game}/${playtype}/scores/${chart.chartID}` ); + const [shouldRefresh, forceRefresh] = useReducer((state) => state + 1, 0); + + // when a user isn't logged in, skip ever making this request. + const { error: targetError, data: targetData } = useApiQuery( + `/users/${currentUser?.id ?? ""}/games/${game}/${playtype}/targets/on-chart/${ + chart.chartID + }`, + undefined, + [shouldRefresh], + currentUser === null + ); + const currentScoreDoc: ScoreDocument | PBScoreDocument | null = useMemo(() => { if (!data) { // dont worry about this null, it never gets below the rquery checks @@ -110,6 +135,23 @@ export default function PBDropdown({ body = ; } else if (view === "rivals") { body = ; + } else if (view === "targets") { + if (currentUser) { + body = ( + + ); + } else { + body = <>not possible, shouldn't've got here.; + } } else { body = ( ({ Play History{histData && ` (${histData.length})`} + {currentUser?.id === userID && ( + + + Goals & Quests{targetData && ` (${targetData.goals.length})`} + + )} Rivals diff --git a/client/src/components/tables/dropdowns/ScoreDropdown.tsx b/client/src/components/tables/dropdowns/ScoreDropdown.tsx index 9f11a57c6..bd3422a6c 100644 --- a/client/src/components/tables/dropdowns/ScoreDropdown.tsx +++ b/client/src/components/tables/dropdowns/ScoreDropdown.tsx @@ -6,15 +6,16 @@ import useApiQuery from "components/util/query/useApiQuery"; import SelectButton from "components/util/SelectButton"; import { UserContext } from "context/UserContext"; import { UserSettingsContext } from "context/UserSettingsContext"; -import React, { useContext, useState } from "react"; +import React, { useContext, useReducer, useState } from "react"; import { ChartDocument, IDStrings, PBScoreDocument, UserDocument, ScoreDocument, + SongDocument, } from "tachi-common"; -import { UGPTChartPBComposition } from "types/api-returns"; +import { GoalsOnChartReturn, UGPTChartPBComposition } from "types/api-returns"; import { GamePT, SetState } from "types/react"; import DocComponentCreator, { DocumentComponentType } from "./components/DocumentComponent"; import DropdownStructure from "./components/DropdownStructure"; @@ -22,6 +23,7 @@ import ManageScore from "./components/ManageScore"; import PBCompare from "./components/PBCompare"; import PlayHistory from "./components/PlayHistory"; import RivalCompare from "./components/RivalCompare"; +import TargetInfo from "./components/TargetInfo"; import { GPTDropdownSettings } from "./GPTDropdownSettings"; export interface ScoreState { @@ -39,14 +41,16 @@ export default function ScoreDropdown({ playtype, user, chart, + song, scoreState, thisScore, defaultView = "moreInfo", }: { user: UserDocument; chart: ChartDocument; + song: SongDocument; scoreState: ScoreState; - defaultView?: "vsPB" | "moreInfo" | "history" | "debug" | "manage" | "rivals"; + defaultView?: "vsPB" | "moreInfo" | "history" | "debug" | "manage" | "rivals" | "targets"; thisScore: ScoreDocument; } & GamePT) { const DocComponent: DocumentComponentType = (props) => @@ -68,6 +72,18 @@ export default function ScoreDropdown({ `/users/${user.id}/games/${game}/${playtype}/scores/${chart.chartID}` ); + const [shouldRefresh, forceRefresh] = useReducer((state) => state + 1, 0); + + // when a user isn't logged in, skip ever making this request. + const { error: targetError, data: targetData } = useApiQuery( + `/users/${currentUser?.id ?? ""}/games/${game}/${playtype}/targets/on-chart/${ + chart.chartID + }`, + undefined, + [shouldRefresh], + currentUser === null + ); + if (error) { return <>An error has occurred. Whoops.; } @@ -107,6 +123,23 @@ export default function ScoreDropdown({ body = ; } else if (view === "manage") { body = ; + } else if (view === "targets") { + if (currentUser) { + body = ( + + ); + } else { + body = <>not possible, shouldn't've got here.; + } } else if (view === "rivals") { body = ; } @@ -125,8 +158,14 @@ export default function ScoreDropdown({ - Play History + Play History{histData && ` (${histData.length})`} + {currentUser?.id === user.id && ( + + + Goals & Quests{targetData && ` (${targetData.goals.length})`} + + )} {currentUser?.id === user.id && settings?.preferences.deletableScores && ( diff --git a/client/src/components/tables/dropdowns/components/TargetInfo.tsx b/client/src/components/tables/dropdowns/components/TargetInfo.tsx new file mode 100644 index 000000000..202a21455 --- /dev/null +++ b/client/src/components/tables/dropdowns/components/TargetInfo.tsx @@ -0,0 +1,68 @@ +import { UnsuccessfulAPIFetchResponse } from "util/api"; +import { CreateGoalSubDataset, CreateUserMap } from "util/data"; +import SetNewGoalModal from "components/targets/SetNewGoalModal"; +import ApiError from "components/util/ApiError"; +import Divider from "components/util/Divider"; +import Loading from "components/util/Loading"; +import React, { useState } from "react"; +import { Button, Col } from "react-bootstrap"; +import { GoalsOnChartReturn } from "types/api-returns"; +import { UGPT } from "types/react"; +import { ChartDocument, SongDocument } from "tachi-common"; +import GoalSubTable from "components/tables/goals/GoalSubTable"; + +export default function TargetInfo({ + data, + error, + game, + playtype, + reqUser, + chart, + song, + onGoalSet, +}: { + data: GoalsOnChartReturn | undefined; + error: UnsuccessfulAPIFetchResponse | null; + chart: ChartDocument; + song: SongDocument; + onGoalSet: () => void; +} & UGPT) { + const [show, setShow] = useState(false); + + if (error) { + return ; + } + + if (!data) { + return ; + } + + const userMap = CreateUserMap([reqUser]); + + return ( +
+ + + + + + + +
+ ); +} diff --git a/client/src/components/tables/folders/FolderTable.tsx b/client/src/components/tables/folders/FolderTable.tsx index acc1b4edd..31d0c6eb2 100644 --- a/client/src/components/tables/folders/FolderTable.tsx +++ b/client/src/components/tables/folders/FolderTable.tsx @@ -4,9 +4,8 @@ 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 { Game, IDStrings, ScoreCalculatedDataLookup, Playtype } from "tachi-common"; import { FolderDataset } from "types/tables"; -import { Playtype } from "tachi-common"; import DifficultyCell from "../cells/DifficultyCell"; import IndicatorsCell from "../cells/IndicatorsCell"; import RankingCell, { RankingViewMode } from "../cells/RankingCell"; @@ -115,6 +114,7 @@ function Row({ game={game} playtype={data.playtype} scoreState={scoreState} + song={data.__related.song} /> } > diff --git a/client/src/components/tables/goals/GoalSubTable.tsx b/client/src/components/tables/goals/GoalSubTable.tsx index 900572412..4324ad691 100644 --- a/client/src/components/tables/goals/GoalSubTable.tsx +++ b/client/src/components/tables/goals/GoalSubTable.tsx @@ -1,61 +1,56 @@ import { ChangeOpacity } from "util/color-opacity"; import { NumericSOV, StrSOV } from "util/sorts"; -import GentleLink from "components/util/GentleLink"; import React from "react"; -import { - COLOUR_SET, - GoalDocument, - UserDocument, - GoalSubscriptionDocument, -} from "tachi-common"; +import { COLOUR_SET } from "tachi-common"; import { GamePT } from "types/react"; +import { GoalSubDataset } from "types/tables"; import TimestampCell from "../cells/TimestampCell"; import UserCell from "../cells/UserCell"; import TachiTable, { Header } from "../components/TachiTable"; - -export type GoalSubDataset = (GoalSubscriptionDocument & { - __related: { - user: UserDocument; - goal: GoalDocument; - }; -})[]; +import GoalSubTitleCell from "../cells/GoalSubTitleCell"; export default function GoalSubTable({ game, playtype, dataset, -}: GamePT & { dataset: GoalSubDataset }) { + showUser = false, + small = false, +}: GamePT & { dataset: GoalSubDataset; showUser?: boolean; small?: boolean }) { const headers: Header[] = [ - ["User", "User", StrSOV((x) => x.__related.user.username)], ["Goal", "Goal", StrSOV((x) => x.__related.goal.name)], [ "Progress", "Progress", NumericSOV((x) => (x.progress === null ? -Infinity : x.progress / x.outOf)), ], - ["Achieved On", "Achieved", NumericSOV((x) => x.timeAchieved ?? -Infinity)], + ["Goal Set", "Goal Set", NumericSOV((x) => x.timeSet)], ]; + if (showUser) { + headers.unshift(["User", "User", StrSOV((x) => x.__related.user.username)]); + } + return ( k.__related.user.username, - goal: (k) => k.__related.goal.name, - timestamp: (k) => k.timeAchieved, - }} + noTopDisplayStr={small} + searchFunctions={ + !small + ? { + user: (k) => k.__related.user.username, + goal: (k) => k.__related.goal.name, + timestamp: (k) => k.timeAchieved, + } + : undefined + } rowFunction={(d) => ( - - - - {d.__related.goal.name} - - + {showUser && ( + + )} + Achieved! ) : ( <> - {d.progress ?? "N/A"} + {d.progressHuman ?? "N/A"} /{d.outOfHuman} )} - + )} /> diff --git a/client/src/components/tables/pbs/PBTable.tsx b/client/src/components/tables/pbs/PBTable.tsx index 29ba45861..87d00e933 100644 --- a/client/src/components/tables/pbs/PBTable.tsx +++ b/client/src/components/tables/pbs/PBTable.tsx @@ -3,9 +3,8 @@ import { CreateDefaultPBSearchParams } from "util/tables/create-search"; import { GetPBLeadingHeaders } from "util/tables/get-pb-leaders"; import useScoreRatingAlg from "components/util/useScoreRatingAlg"; import React, { useState } from "react"; -import { Game, IDStrings, ScoreCalculatedDataLookup } from "tachi-common"; +import { Game, IDStrings, ScoreCalculatedDataLookup, Playtype } from "tachi-common"; import { PBDataset } from "types/tables"; -import { Playtype } from "tachi-common"; import DropdownIndicatorCell from "../cells/DropdownIndicatorCell"; import IndexCell from "../cells/IndexCell"; import RankingCell, { RankingViewMode } from "../cells/RankingCell"; @@ -127,6 +126,7 @@ function Row({ dropdown={ ({ dropdown={ ; + onNewGoalSet?: () => void; + preData: { chart: ChartDocument; song: SongDocument } | FolderDocument; +} & UGPT) { + const gptConfig = GetGamePTConfig(game, playtype); + + const [criteria, setCriteria] = useState({ + mode: "single", + key: gptConfig.scoreBucket === "grade" ? "scoreData.gradeIndex" : "scoreData.lampIndex", + value: + gptConfig.scoreBucket === "grade" + ? gptConfig.grades.indexOf(gptConfig.clearGrade) + : gptConfig.lamps.indexOf(gptConfig.clearLamp), + }); + + const charts = useMemo( + () => + "folderID" in preData + ? { + type: "folder", + data: preData.folderID, + } + : { + type: "single", + data: preData.chart.chartID, + }, + [preData] + ); + + const identifier = + "folderID" in preData + ? `The '${preData.title}' folder` + : FormatChart(game, preData.song, preData.chart); + + return ( + setShow(false)}> + + Set Goal for {identifier} + + + + {/* + + */} + + + + + + + + + + + + ); +} + +function RenderGoalChartSet({ + charts, + identifier, +}: { + charts: GoalDocument["charts"]; + identifier: string; +}) { + switch (charts.type) { + case "any": + return
Any Chart
; + case "single": + return
Selected Chart: {identifier}
; + case "folder": + return
Selected Folder: {identifier}
; + case "multi": + return
Selected Charts: {identifier}
; + } +} + +function RenderGoalCriteriaPicker({ + criteria, + charts, + setCriteria, + game, + playtype, +}: { + criteria: GoalDocument["criteria"]; + setCriteria: SetState; + charts: GoalDocument["charts"]; + game: Game; + playtype: Playtype; +}) { + return ( + <> +
+ + is greater than or equal to +
+ + setCriteria({ + ...criteria, + value, + }) + } + /> +
+
+ {charts.type !== "single" && ( + <> + + { + if (mode === "single" && countNum === undefined) { + setCriteria({ + ...criteria, + mode, + }); + } else { + setCriteria({ + ...criteria, + // shouldn't happen, but default to 0 ig + countNum: countNum ?? 0, + mode, + }); + } + }} + /> + + )} + + ); +} + +function CriteriaModePicker({ + criteria, + onChange, +}: { + criteria: GoalDocument["criteria"]; + onChange: (value: GoalDocument["criteria"]["mode"], countNum?: number) => void; +} & GamePT) { + const [absCountNum, setAbsCountNum] = useState(10); + const [perCountNum, setPerCountNum] = useState(10); + + useEffect(() => { + if (criteria.mode === "proportion") { + onChange("proportion", perCountNum / 100); + } else if (criteria.mode === "absolute") { + onChange("absolute", absCountNum); + } + }, [absCountNum, perCountNum]); + + return ( + <> +
+ onChange("single")} + />{" "} + On any chart in this folder +
+
+ onChange("absolute", Number(absCountNum))} + />{" "} + On{" "} + setAbsCountNum(Number(e.target.value))} + type="number" + min={0} + value={absCountNum} + />{" "} + {absCountNum === 1 ? "chart" : "charts"} in this folder +
+
+ onChange("proportion", perCountNum / 100)} + />{" "} + On + setPerCountNum(clamp(Number(e.target.value), 0, 100))} + type="number" + min={0} + max={100} + value={perCountNum} + /> + % of charts in this folder +
+ + ); +} + +function CriteriaValuePicker({ + criteria, + game, + playtype, + onChange, +}: { + criteria: GoalDocument["criteria"]; + onChange: (value: GoalDocument["criteria"]["value"]) => void; +} & GamePT) { + const gptConfig = GetGamePTConfig(game, playtype); + + switch (criteria.key) { + case "scoreData.score": + return ( + onChange(Number(e.target.value))} + type="number" + min={0} + value={criteria.value} + /> + ); + case "scoreData.percent": + return ( + onChange(Number(e.target.value))} + type="number" + value={criteria.value} + min={0} + max={gptConfig.percentMax} + /> + ); + case "scoreData.gradeIndex": + return ( + + ); + + case "scoreData.lampIndex": + return ( + + ); + } +} + +function getBaseKeyValue(game: Game, playtype: Playtype, key: GoalDocument["criteria"]["key"]) { + const gptConfig = GetGamePTConfig(game, playtype); + + switch (key) { + case "scoreData.gradeIndex": + return gptConfig.grades.indexOf(gptConfig.clearGrade); + case "scoreData.lampIndex": + return gptConfig.lamps.indexOf(gptConfig.clearLamp); + default: + return 0; + } +} diff --git a/client/src/components/util/query/useApiQuery.tsx b/client/src/components/util/query/useApiQuery.tsx index 160899217..99a50818b 100644 --- a/client/src/components/util/query/useApiQuery.tsx +++ b/client/src/components/util/query/useApiQuery.tsx @@ -5,7 +5,8 @@ import { SuccessfulAPIResponse } from "tachi-common"; export default function useApiQuery( url: string | string[], options?: RequestInit, - additionalDeps?: string[] + additionalDeps?: string[], + skip = false ) { const deps = []; @@ -22,6 +23,10 @@ export default function useApiQuery( return useQuery( deps, async () => { + if (skip) { + throw new Error("Skipped"); + } + if (Array.isArray(url)) { const results = await Promise.all(url.map((u) => APIFetchV1(u, options))); diff --git a/client/src/types/api-returns.ts b/client/src/types/api-returns.ts index cb130ee01..3db092eb8 100644 --- a/client/src/types/api-returns.ts +++ b/client/src/types/api-returns.ts @@ -25,6 +25,8 @@ import { UGSRatingsLookup, UserGameStats, UserGameStatsSnapshot, + QuestDocument, + QuestSubscriptionDocument, } from "tachi-common"; export interface UGPTStatsReturn { @@ -231,3 +233,12 @@ export interface ActivityReturn { } export type RecordActivityReturn = Partial>; + +export interface GoalsOnChartReturn { + goals: Array; + goalSubs: Array; + quests: Array; + questSubs: Array; +} + +export type GoalsOnFolderReturn = GoalsOnChartReturn; diff --git a/client/src/types/tables.ts b/client/src/types/tables.ts index ae10511e2..70163468b 100644 --- a/client/src/types/tables.ts +++ b/client/src/types/tables.ts @@ -10,6 +10,9 @@ import { ScoreDocument, SongDocument, UserGameStats, + GoalSubscriptionDocument, + GoalDocument, + QuestDocument, } from "tachi-common"; export type PBDataset = (PBScoreDocument & { @@ -81,3 +84,11 @@ export type FailedImportDataset = Array< }; } >; + +export type GoalSubDataset = (GoalSubscriptionDocument & { + __related: { + user: UserDocument; + goal: GoalDocument; + parentQuests: Array; + }; +})[]; diff --git a/client/src/util/data.ts b/client/src/util/data.ts index b0750236d..1ac49f1b2 100644 --- a/client/src/util/data.ts +++ b/client/src/util/data.ts @@ -8,7 +8,9 @@ import { ScoreDocument, SessionScoreInfo, SongDocument, + QuestDocument, } from "tachi-common"; +import { GoalsOnChartReturn, GoalsOnFolderReturn } from "types/api-returns"; export function GetPBs(scoreInfo: SessionScoreInfo[]) { return scoreInfo.filter((e) => e.isNewScore === true || e.lampDelta > 0 || e.scoreDelta > 0); @@ -83,3 +85,53 @@ export function CreateChartLink(chart: ChartDocument, game: Game) { return `/dashboard/games/${game}/${chart.playtype}/songs/${chart.songID}/${chart.chartID}`; } + +// stolen from server +function GetGoalIDsFromQuest(quest: QuestDocument) { + // this sucks - maybe a nicer way to do this, because nested + // maps are just ugly + return quest.questData.map((e) => e.goals.map((e) => e.goalID)).flat(1); +} + +export function CreateGoalSubDataset( + data: GoalsOnChartReturn | GoalsOnFolderReturn, + userMap: Map +) { + const dataset = []; + const goalMap = CreateGoalMap(data.goals); + + for (const sub of data.goalSubs) { + const goal = goalMap.get(sub.goalID); + + if (!goal) { + console.warn( + `No goal was sent for ${sub.userID}:${sub.goalID}, yet was a subscription?` + ); + continue; + } + + const user = userMap.get(sub.userID); + + if (!user) { + console.warn( + `No user was set for ${sub.userID}:${sub.goalID}, yet was a subscription?` + ); + continue; + } + + const parentQuests = data.quests.filter((q) => + GetGoalIDsFromQuest(q).includes(goal.goalID) + ); + + dataset.push({ + ...sub, + __related: { + goal, + user, + parentQuests, + }, + }); + } + + return dataset; +} diff --git a/client/src/util/misc.ts b/client/src/util/misc.ts index dff85b12d..edfcf9509 100644 --- a/client/src/util/misc.ts +++ b/client/src/util/misc.ts @@ -13,6 +13,7 @@ import { ScoreDocument, SessionDocument, Playtype, + QuestDocument, } from "tachi-common"; import fjsh from "fast-json-stable-hash"; @@ -532,3 +533,25 @@ export function JoinJSX(elements: Array, joiner: JSX.Element): Arra return newArray; } + +export function clamp(a: number, low: number, up: number) { + if (a < low) { + return low; + } + + if (a > up) { + return up; + } + + return a; +} + +export function CreateQuestMap(quests: Array) { + const map = new Map(); + + for (const q of quests) { + map.set(q.questID, q); + } + + return map; +} diff --git a/common/src/config/config.ts b/common/src/config/config.ts index bfbd2cfb9..e4ecc88d0 100644 --- a/common/src/config/config.ts +++ b/common/src/config/config.ts @@ -23,17 +23,17 @@ import type { GPTTierlists } from ".."; import type { ClassInfo, GameClassSets } from "../game-classes"; import type { ESDJudgementFormat } from "../lib/esd"; import type { - Playtypes, - Game, Difficulties, + Game, + GPTSupportedVersions, + Grades, IDStrings, + JudgementLookup, + Lamps, + Playtypes, ScoreCalculatedDataLookup, SessionCalculatedDataLookup, UGSRatingsLookup, - Grades, - Lamps, - JudgementLookup, - GPTSupportedVersions, } from "../types"; export interface GameConfig { @@ -74,7 +74,7 @@ interface BaseGamePTConfig { grades: Array; gradeColours: Record; clearGrade: Grades[I]; - gradeBoundaries: Array | null; + gradeBoundaries: Array; lamps: Array; lampColours: Record; @@ -2066,7 +2066,7 @@ const GAME_PT_CONFIGS: GamePTConfigs = { EXC: COLOUR_SET.white, }, clearGrade: "A", - gradeBoundaries: null, + gradeBoundaries: [0, 50, 70, 80, 85, 90, 95, 98, 100], lamps: ["FAILED", "CLEAR", "FULL COMBO", "EXCELLENT"], lampColours: { diff --git a/server/src/lib/score-import/framework/common/score-utils.ts b/server/src/lib/score-import/framework/common/score-utils.ts index ee09cc78c..97e70154b 100644 --- a/server/src/lib/score-import/framework/common/score-utils.ts +++ b/server/src/lib/score-import/framework/common/score-utils.ts @@ -25,12 +25,6 @@ export function GetGradeFromPercent( const boundaries = gptConfig.gradeBoundaries; const grades = gptConfig.grades; - if (!boundaries) { - throw new Error( - `Invalid call to GetGradeFromPercent! GPT ${game}:${playtype} does not use grade boundaries.` - ); - } - // (hey, this for loop is backwards!) for (let i = boundaries.length - 1; i >= 0; i--) { if (percent + Number.EPSILON >= NotNullish(boundaries[i])) { diff --git a/server/src/lib/score-import/framework/goals/goals.ts b/server/src/lib/score-import/framework/goals/goals.ts index 8a59bc924..b96f84c06 100644 --- a/server/src/lib/score-import/framework/goals/goals.ts +++ b/server/src/lib/score-import/framework/goals/goals.ts @@ -1,5 +1,5 @@ import db from "external/mongo/db"; -import { EvaluateGoalForUser } from "lib/targets/goals"; +import { EvaluateGoalForUser, GetRelevantGoals } from "lib/targets/goals"; import { EmitWebhookEvent } from "lib/webhooks/webhooks"; import type { KtLogger } from "lib/logger/logger"; import type { Game, GoalDocument, GoalSubscriptionDocument, integer } from "tachi-common"; @@ -184,106 +184,3 @@ export async function ProcessGoal( webhookEvent, }; } - -/** - * Gets the goals the user has set for this game and playtype. - * Then, filters it based on the chartIDs involved in this import. - * - * This optimisation allows users to have *lots* of goals, but only ever - * evaluate the ones we need to. - * @returns An array of Goals, and an array of goalSubs. - */ -export async function GetRelevantGoals( - game: Game, - userID: integer, - chartIDs: Set, - logger: KtLogger -): Promise<{ goals: Array; goalSubsMap: Map }> { - const goalSubs = await db["goal-subs"].find({ game, userID }); - - logger.verbose(`Found user has ${goalSubs.length} goals.`); - - if (!goalSubs.length) { - return { goals: [], goalSubsMap: new Map() }; - } - - const goalIDs = goalSubs.map((e) => e.goalID); - - const chartIDsArr: Array = []; - - for (const c of chartIDs) { - chartIDsArr.push(c); - } - - const goals = await Promise.all([ - // this gets the relevantGoals for direct and multi - db.goals.find({ - "charts.type": { $in: ["single", "multi"] }, - "charts.data": { $in: chartIDsArr }, - goalID: { $in: goalIDs }, - }), - db.goals.find({ - "charts.type": "any", - goalID: { $in: goalIDs }, - }), - GetRelevantFolderGoals(goalIDs, chartIDsArr), - ]).then((r) => r.flat(1)); - - const goalSet = new Set(goals.map((e) => e.goalID)); - - const goalSubsMap: Map = new Map(); - - for (const goalSub of goalSubs) { - if (!goalSet.has(goalSub.goalID)) { - continue; - } - - // since these are guaranteed to be unique, lets make a hot map of goalID -> goalSubDocument, so we can - // pull them in for post-processing and filter out the goalSubDocuments that aren't relevant. - goalSubsMap.set(goalSub.goalID, goalSub); - } - - return { - goals, - goalSubsMap, - }; -} - -/** - * Returns the set of goals where its folder contains any member - * of chartIDsArr. - */ -export function GetRelevantFolderGoals(goalIDs: Array, chartIDsArr: Array) { - // Slightly black magic - this is kind of like doing an SQL join. - // it's weird to do this in mongodb, but this seems like the right - // way to actually handle this. - - const result: Promise> = db.goals.aggregate([ - { - $match: { - "charts.type": "folder", - goalID: { $in: goalIDs }, - }, - }, - { - $lookup: { - from: "folder-chart-lookup", - localField: "charts.data", - foreignField: "folderID", - as: "folderCharts", - }, - }, - { - $match: { - "folderCharts.chartID": { $in: chartIDsArr }, - }, - }, - { - $project: { - folderCharts: 0, - }, - }, - ]); - - return result; -} diff --git a/server/src/lib/targets/goals.ts b/server/src/lib/targets/goals.ts index c9401743f..310d33985 100644 --- a/server/src/lib/targets/goals.ts +++ b/server/src/lib/targets/goals.ts @@ -11,12 +11,12 @@ import type { FilterQuery } from "mongodb"; import type { Game, GoalDocument, + GoalSubscriptionDocument, integer, PBScoreDocument, Playtype, - GoalSubscriptionDocument, - QuestSubscriptionDocument, QuestDocument, + QuestSubscriptionDocument, } from "tachi-common"; const logger = CreateLogCtx(__filename); @@ -96,7 +96,8 @@ export async function EvaluateGoalForUser( goal.playtype, goal.criteria.key, goal.criteria.value, - null + null, + true ); if (res) { @@ -242,7 +243,8 @@ export function HumaniseGoalProgress( playtype: Playtype, key: GoalKeys, value: number, - userPB: PBScoreDocument | null + userPB: PBScoreDocument | null, + isOutOf = false ): string { const gptConfig = GetGamePTConfig(game, playtype); @@ -254,6 +256,12 @@ export function HumaniseGoalProgress( ); } + if (isOutOf) { + return `${gptConfig.grades[value]} (${gptConfig.gradeBoundaries[value]?.toFixed( + 2 + )}%)`; + } + return `${gptConfig.grades[value]} (${userPB?.scoreData.percent.toFixed(2) ?? "0"}%)`; } @@ -264,12 +272,20 @@ export function HumaniseGoalProgress( ); } + // these games care about the BP as progress towards the goal if (userPB && (game === "iidx" || game === "bms" || game === "pms")) { return `${gptConfig.lamps[value]} (BP: ${ (userPB as PBWithBadPoor).scoreData.hitMeta.bp ?? "N/A" })`; } + // this game cares about where you died relative to the measures in the chart + if (game === "itg") { + return `${gptConfig.lamps[value]} (BP: ${ + (userPB as PBScoreDocument<"itg:Stamina">).scoreData.hitMeta.diedAt ?? "N/A" + })`; + } + return NotNullish(gptConfig.lamps[value]); } @@ -390,10 +406,10 @@ export function GetQuestsThatContainGoal(goalID: string) { * * If this query matches none, an empty array is returned. */ -export async function GetBlockingParentQuestSubs( +export async function GetParentQuestSubs( goalSub: GoalSubscriptionDocument ): Promise> { - const blockers: Array = await db[ + const parents: Array = await db[ "quest-subs" ].aggregate([ { @@ -429,5 +445,131 @@ export async function GetBlockingParentQuestSubs( }, ]); - return blockers; + return parents; +} + +/** + * Gets the goals the user has set for this game and playtype. + * Then, filters it based on the chartIDs involved in this import. + * + * This optimisation allows users to have *lots* of goals, but only ever + * evaluate the ones we need to. + * + * @param onlyUnachieved - optionally, pass "onlyUnachieved=true" to limit this to + * only goals that the user has not achieved. + * @param excludeAny - optionally, pass "excludeAny=true" to ignore goals that match + * "any" chart. + * @returns An array of Goals, and an array of goalSubs. + */ +export async function GetRelevantGoals( + game: Game, + userID: integer, + chartIDs: Set, + logger: KtLogger, + onlyUnachieved = false, + excludeAny = false +): Promise<{ goals: Array; goalSubsMap: Map }> { + const gsQuery: FilterQuery = { + game, + userID, + }; + + if (onlyUnachieved) { + gsQuery.achieved = false; + } + + const goalSubs = await db["goal-subs"].find(gsQuery); + + logger.verbose(`Found user has ${goalSubs.length} goals.`); + + if (!goalSubs.length) { + return { goals: [], goalSubsMap: new Map() }; + } + + const goalIDs = goalSubs.map((e) => e.goalID); + + const chartIDsArr: Array = []; + + for (const c of chartIDs) { + chartIDsArr.push(c); + } + + const promises = [ + // this gets the relevantGoals for direct and multi + db.goals.find({ + "charts.type": { $in: ["single", "multi"] }, + "charts.data": { $in: chartIDsArr }, + goalID: { $in: goalIDs }, + }), + GetRelevantFolderGoals(goalIDs, chartIDsArr), + ]; + + if (!excludeAny) { + promises.push( + db.goals.find({ + "charts.type": "any", + goalID: { $in: goalIDs }, + }) + ); + } + + const goals = await Promise.all(promises).then((r) => r.flat(1)); + + const goalSet = new Set(goals.map((e) => e.goalID)); + + const goalSubsMap: Map = new Map(); + + for (const goalSub of goalSubs) { + if (!goalSet.has(goalSub.goalID)) { + continue; + } + + // since these are guaranteed to be unique, lets make a hot map of goalID -> goalSubDocument, so we can + // pull them in for post-processing and filter out the goalSubDocuments that aren't relevant. + goalSubsMap.set(goalSub.goalID, goalSub); + } + + return { + goals, + goalSubsMap, + }; +} + +/** + * Returns the set of goals where its folder contains any member + * of chartIDsArr. + */ +export function GetRelevantFolderGoals(goalIDs: Array, chartIDsArr: Array) { + // Slightly black magic - this is kind of like doing an SQL join. + // it's weird to do this in mongodb, but this seems like the right + // way to actually handle this. + + const result: Promise> = db.goals.aggregate([ + { + $match: { + "charts.type": "folder", + goalID: { $in: goalIDs }, + }, + }, + { + $lookup: { + from: "folder-chart-lookup", + localField: "charts.data", + foreignField: "folderID", + as: "folderCharts", + }, + }, + { + $match: { + "folderCharts.chartID": { $in: chartIDsArr }, + }, + }, + { + $project: { + folderCharts: 0, + }, + }, + ]); + + return result; } diff --git a/server/src/lib/targets/quests.ts b/server/src/lib/targets/quests.ts index 79b1b06a2..226f3709f 100644 --- a/server/src/lib/targets/quests.ts +++ b/server/src/lib/targets/quests.ts @@ -5,9 +5,11 @@ import CreateLogCtx from "lib/logger/logger"; import { BulkSendNotification } from "lib/notifications/notifications"; import type { EvaluatedGoalReturn } from "./goals"; import type { + Game, GoalDocument, GoalSubscriptionDocument, integer, + Playtype, QuestDocument, QuestSubscriptionDocument, } from "tachi-common"; @@ -316,3 +318,36 @@ export async function UpdateQuestSubscriptions(questID: string) { return subscriptionResults; } + +/** + * Given an array of user goal subscriptions, return all the quests this user is + * subscribed to that subsume these goals. + */ +export async function GetParentQuests( + userID: integer, + game: Game, + playtype: Playtype, + goalSubs: Array +) { + const questSubs: Array<{ questID: string }> = await db["quest-subs"].find( + { + game, + playtype, + userID, + }, + { + projection: { + questID: 1, + }, + } + ); + + const questSubIDs = questSubs.map((e) => e.questID); + + const quests = await db.quests.find({ + questID: { $in: questSubIDs }, + "quest.questData.goals.goalID": { $in: goalSubs.map((e) => e.goalID) }, + }); + + return quests; +} diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts index 89ea75b7f..37b493769 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts @@ -6,7 +6,7 @@ import CreateLogCtx from "lib/logger/logger"; import { ServerConfig } from "lib/setup/config"; import { ConstructGoal, - GetBlockingParentQuestSubs, + GetParentQuestSubs, GetQuestsThatContainGoal, SubscribeToGoal, } from "lib/targets/goals"; @@ -257,7 +257,7 @@ router.delete( const goalSub = GetTachiData(req, "goalSubDoc"); - const parentQuests = await GetBlockingParentQuestSubs(goalSub); + const parentQuests = await GetParentQuestSubs(goalSub); if (parentQuests.length) { return res.status(400).json({ diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts index 5017d616c..73bd1c5a2 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts @@ -1,14 +1,21 @@ import goalsRouter from "./goals/router"; import questsRouter from "./quests/router"; import { Router } from "express"; +import db from "external/mongo/db"; +import CreateLogCtx from "lib/logger/logger"; +import { GetRelevantGoals } from "lib/targets/goals"; +import { GetParentQuests } from "lib/targets/quests"; import { GetRecentlyAchievedGoals, GetRecentlyAchievedQuests, GetRecentlyInteractedGoals, GetRecentlyInteractedQuests, } from "utils/db"; +import { GetFolderChartIDs } from "utils/folder"; import { GetUGPT } from "utils/req-tachi-data"; +const logger = CreateLogCtx(__filename); + const router: Router = Router({ mergeParams: true }); /** @@ -68,6 +75,123 @@ router.get("/recently-raised", async (req, res) => { }); }); +/** + * Find what unachieved targets this user has set that consider this chart. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/on-chart/:chartID + */ +router.get("/on-chart/:chartID", async (req, res) => { + const { game, playtype, user } = GetUGPT(req); + + const chartID = req.params.chartID; + + const chart = await db.charts[game].findOne({ chartID, playtype }); + + if (!chart) { + return res.status(404).json({ + success: false, + description: `Failed to find a chart with chartID '${chartID}'.`, + }); + } + + const { goals, goalSubsMap } = await GetRelevantGoals( + game, + user.id, + new Set([chartID]), + logger, + true + ); + + const goalSubs = [...goalSubsMap.values()]; + + const quests = await GetParentQuests(user.id, game, playtype, goalSubs); + + const questSubs = await db["quest-subs"].find({ + questID: { $in: quests.map((e) => e.questID) }, + }); + + return res.status(200).json({ + success: true, + description: `Found pertinent goals`, + body: { + goals, + goalSubs, + quests, + questSubs, + }, + }); +}); + +/** + * Find what unachieved targets this user has set that involve this folder. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/targets/on-folder/:folderID + */ +router.get("/on-folder/:folderID", async (req, res) => { + const { game, playtype, user } = GetUGPT(req); + + const folderID = req.params.folderID; + + const folder = await db.folders.findOne({ folderID, playtype }); + + if (!folder) { + return res.status(404).json({ + success: false, + description: `Failed to find a folder with folderID '${folderID}'.`, + }); + } + + const folderChartIDs = await GetFolderChartIDs(folderID); + + const allGoalSubs = await db["goal-subs"].find({ + userID: user.id, + game, + playtype, + }); + + const goalIDs = allGoalSubs.map((e) => e.goalID); + + // goals are relevant to a folder if it's part of a folder type goal + // or if it's on any of the charts in the folder. + // this is convenient for the UI, atleast. + const goals = await Promise.all([ + db.goals.find({ + "charts.type": { $in: ["single", "multi"] }, + "charts.data": { $in: folderChartIDs }, + goalID: { $in: goalIDs }, + }), + db.goals.find({ + "charts.type": "folder", + "charts.data": folderID, + goalID: { $in: goalIDs }, + }), + ]).then((r) => r.flat()); + + const goalSubs = await db["goal-subs"].find({ + goalID: { $in: goals.map((e) => e.goalID) }, + userID: user.id, + game, + playtype, + }); + + const quests = await GetParentQuests(user.id, game, playtype, goalSubs); + + const questSubs = await db["quest-subs"].find({ + questID: { $in: quests.map((e) => e.questID) }, + }); + + return res.status(200).json({ + success: true, + description: `Found pertinent goals`, + body: { + goals, + goalSubs, + quests, + questSubs, + }, + }); +}); + router.use("/goals", goalsRouter); router.use("/quests", questsRouter);