From f889ecdd83d80ef626bcb1e910bde6bbe3aca74d Mon Sep 17 00:00:00 2001
From: zkldi <20380519+zkldi@users.noreply.github.com>
Date: Tue, 15 Nov 2022 04:16:16 +0000
Subject: [PATCH] feat: initial goals and quests
---
.../src/app/pages/dashboard/DashboardPage.tsx | 8 +-
.../_playtype/folders/FolderQuestsPage.tsx | 78 ++++
.../_playtype/folders/SpecificFolderPage.tsx | 17 +-
.../tables/cells/GoalSubTitleCell.tsx | 52 +++
.../tables/dropdowns/PBDropdown.tsx | 56 ++-
.../tables/dropdowns/ScoreDropdown.tsx | 47 ++-
.../dropdowns/components/TargetInfo.tsx | 68 ++++
.../components/tables/folders/FolderTable.tsx | 4 +-
.../components/tables/goals/GoalSubTable.tsx | 59 ++-
client/src/components/tables/pbs/PBTable.tsx | 4 +-
.../components/tables/scores/ScoreTable.tsx | 1 +
.../components/targets/SetNewGoalModal.tsx | 376 ++++++++++++++++++
.../src/components/util/query/useApiQuery.tsx | 7 +-
client/src/types/api-returns.ts | 11 +
client/src/types/tables.ts | 11 +
client/src/util/data.ts | 52 +++
client/src/util/misc.ts | 23 ++
common/src/config/config.ts | 16 +-
.../framework/common/score-utils.ts | 6 -
.../lib/score-import/framework/goals/goals.ts | 105 +----
server/src/lib/targets/goals.ts | 156 +++++++-
server/src/lib/targets/quests.ts | 35 ++
.../_game/_playtype/targets/goals/router.ts | 4 +-
.../games/_game/_playtype/targets/router.ts | 124 ++++++
24 files changed, 1142 insertions(+), 178 deletions(-)
create mode 100644 client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderQuestsPage.tsx
create mode 100644 client/src/components/tables/cells/GoalSubTitleCell.tsx
create mode 100644 client/src/components/tables/dropdowns/components/TargetInfo.tsx
create mode 100644 client/src/components/targets/SetNewGoalModal.tsx
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);