feat: initial goals and quests

This commit is contained in:
zkldi
2022-11-15 04:16:16 +00:00
parent fc64076ddb
commit f889ecdd83
24 changed files with 1142 additions and 178 deletions
@@ -54,11 +54,11 @@ function DashboardLoggedIn({ user }: { user: UserDocument }) {
<span className="display-4">
{splash}, {user.username}.
</span>
<div className="card mt-4">
<div className="card my-4">
<DashboardHeader />
</div>
<RecentInfo user={user} />
<Divider />
<RecentInfo user={user} />
<Switch>
<Route exact path="/dashboard">
<DashboardActivity user={user} />
@@ -156,7 +156,7 @@ function RecentInfo({ user }: { user: UserDocument }) {
<Divider />
</>
)}
{data.recentFolders.length !== 0 && (
{/* {data.recentFolders.length !== 0 && (
<>
<h1>Here's some folders you checked out recently.</h1>
<Divider />
@@ -174,7 +174,7 @@ function RecentInfo({ user }: { user: UserDocument }) {
</div>
<Divider />
</>
)}
)} */}
</>
);
}
@@ -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<GoalsOnChartReturn>(
`/users/${reqUser.id}/games/${game}/${playtype}/targets/on-folder/${folder.folderID}`,
undefined,
[refresh]
);
const [show, setShow] = useState(false);
return (
<div>
<Col xs={12} className="w-100 d-flex justify-content-center">
<Button variant="outline-success" onClick={() => setShow(true)}>
Set New Folder Goal
</Button>
</Col>
<Divider />
{error && <ApiError error={error} />}
{data ? (
<FolderQuestsInner {...{ reqUser, game, playtype, folder, data }} />
) : (
<Loading />
)}
{show && (
<SetNewGoalModal
{...{ game, playtype, reqUser, show, setShow }}
preData={folder}
onNewGoalSet={forceRefresh}
/>
)}
</div>
);
}
function FolderQuestsInner({
reqUser,
game,
playtype,
folder,
data,
}: {
data: GoalsOnFolderReturn;
folder: FolderDocument;
} & UGPT) {
const userMap = CreateUserMap([reqUser]);
return (
<GoalSubTable
dataset={CreateGoalSubDataset(data, userMap)}
game={game}
playtype={playtype}
/>
);
}
@@ -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) {
<Divider />
</div>
<div className="col-12 d-flex">
<div className="btn-group mx-auto">
<div className="btn-group d-flex w-100">
<SelectLinkButton to={base}>
<Icon type="table" />
Normal View
@@ -136,6 +137,10 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
<Icon type="users" />
Compare Against User
</SelectLinkButton>
<SelectLinkButton to={`${base}/targets`}>
<Icon type="scroll" />
Goals & Quests
</SelectLinkButton>
</div>
</div>
<div className="col-12">
@@ -171,6 +176,14 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
folder={data.folder}
/>
</Route>
<Route exact path={`${base}/targets`}>
<FolderQuestsPage
game={game}
playtype={playtype}
reqUser={reqUser}
folder={data.folder}
/>
</Route>
</Switch>
</div>
</div>
@@ -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 (
<td>
<GentleLink to={`/dashboard/games/${game}/${playtype}/goals/${data.goalID}`}>
{data.__related.goal.name}
</GentleLink>
{data.__related.parentQuests.length > 0 && (
<>
<br />
Part of:
{fmtQuests(data.__related.parentQuests)}
</>
)}
</td>
);
}
function fmtQuests(quests: Array<QuestDocument>) {
const strs = [];
for (let i = 0; i < Math.min(quests.length, 5); i++) {
const quest = quests[i];
strs.push(
<GentleLink
to={`/dashboard/games/${quest.game}/${quest.playtype}/quests/${quest.questID}`}
>
{quest.name}
</GentleLink>
);
}
if (quests.length > 5) {
strs.push(<Muted>And {quests.length - 5} more...</Muted>);
}
return JoinJSX(strs, <>, </>);
}
@@ -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<I extends IDStrings = IDStrings> {
@@ -32,12 +41,16 @@ export default function PBDropdown<I extends IDStrings = IDStrings>({
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<I extends IDStrings = IDStrings>({
`/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<GoalsOnChartReturn>(
`/users/${currentUser?.id ?? ""}/games/${game}/${playtype}/targets/on-chart/${
chart.chartID
}`,
undefined,
[shouldRefresh],
currentUser === null
);
const currentScoreDoc: ScoreDocument<I> | PBScoreDocument<I> | null = useMemo(() => {
if (!data) {
// dont worry about this null, it never gets below the rquery checks
@@ -110,6 +135,23 @@ export default function PBDropdown<I extends IDStrings = IDStrings>({
body = <DebugContent data={data} />;
} else if (view === "rivals") {
body = <RivalCompare chart={chart} game={game} />;
} else if (view === "targets") {
if (currentUser) {
body = (
<TargetInfo
game={game}
playtype={playtype}
reqUser={currentUser}
data={targetData}
error={targetError}
chart={chart}
song={song}
onGoalSet={forceRefresh}
/>
);
} else {
body = <>not possible, shouldn't've got here.</>;
}
} else {
body = (
<DocComponent
@@ -146,6 +188,12 @@ export default function PBDropdown<I extends IDStrings = IDStrings>({
<Icon type="history" />
Play History{histData && ` (${histData.length})`}
</SelectButton>
{currentUser?.id === userID && (
<SelectButton setValue={setView} value={view} id="targets">
<Icon type="scroll" />
Goals & Quests{targetData && ` (${targetData.goals.length})`}
</SelectButton>
)}
<SelectButton setValue={setView} value={view} id="rivals">
<Icon type="users" />
Rivals
@@ -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<I extends IDStrings = IDStrings>({
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<I extends IDStrings = IDStrings>({
`/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<GoalsOnChartReturn>(
`/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<I extends IDStrings = IDStrings>({
body = <PBCompare data={data} DocComponent={DocComponent} scoreState={scoreState} />;
} else if (view === "manage") {
body = <ManageScore score={thisScore} />;
} else if (view === "targets") {
if (currentUser) {
body = (
<TargetInfo
game={game}
playtype={playtype}
reqUser={currentUser}
data={targetData}
error={targetError}
chart={chart}
song={song}
onGoalSet={forceRefresh}
/>
);
} else {
body = <>not possible, shouldn't've got here.</>;
}
} else if (view === "rivals") {
body = <RivalCompare chart={chart} game={game} />;
}
@@ -125,8 +158,14 @@ export default function ScoreDropdown<I extends IDStrings = IDStrings>({
</SelectButton>
<SelectButton setValue={setView} value={view} id="history">
<Icon type="history" />
Play History
Play History{histData && ` (${histData.length})`}
</SelectButton>
{currentUser?.id === user.id && (
<SelectButton setValue={setView} value={view} id="targets">
<Icon type="scroll" />
Goals & Quests{targetData && ` (${targetData.goals.length})`}
</SelectButton>
)}
{currentUser?.id === user.id && settings?.preferences.deletableScores && (
<SelectButton setValue={setView} value={view} id="manage">
<Icon type="trash" />
@@ -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 <ApiError error={error} />;
}
if (!data) {
return <Loading />;
}
const userMap = CreateUserMap([reqUser]);
return (
<div className="w-100">
<Col xs={12}>
<GoalSubTable
small
dataset={CreateGoalSubDataset(data, userMap)}
game={game}
playtype={playtype}
/>
</Col>
<Divider />
<Button variant="outline-success" onClick={() => setShow(true)}>
Set New Goal
</Button>
<SetNewGoalModal
show={show}
setShow={setShow}
game={game}
playtype={playtype}
reqUser={reqUser}
preData={{ chart, song }}
onNewGoalSet={onGoalSet}
/>
</div>
);
}
@@ -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<I extends IDStrings = IDStrings>({
game={game}
playtype={data.playtype}
scoreState={scoreState}
song={data.__related.song}
/>
}
>
@@ -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<GoalSubDataset[0]>[] = [
["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 (
<TachiTable
dataset={dataset}
headers={headers}
entryName="Goals"
searchFunctions={{
user: (k) => 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) => (
<tr>
<UserCell game={game} playtype={playtype} user={d.__related.user} />
<td>
<GentleLink
to={`/dashboard/games/${game}/${playtype}/targets/goals/${d.goalID}`}
>
{d.__related.goal.name}
</GentleLink>
</td>
{showUser && (
<UserCell game={game} playtype={playtype} user={d.__related.user} />
)}
<GoalSubTitleCell game={game} playtype={playtype} data={d} />
<td
style={{
backgroundColor: d.achieved
@@ -67,12 +62,12 @@ export default function GoalSubTable({
<b>Achieved!</b>
) : (
<>
<b>{d.progress ?? "N/A"}</b>
<b>{d.progressHuman ?? "N/A"}</b>
<small>/{d.outOfHuman}</small>
</>
)}
</td>
<TimestampCell time={d.timeAchieved} />
<TimestampCell time={d.timeSet} />
</tr>
)}
/>
+2 -2
View File
@@ -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<I extends IDStrings = IDStrings>({
dropdown={
<PBDropdown
chart={pb.__related.chart}
song={pb.__related.song}
userID={pb.userID}
game={pb.game}
playtype={pb.playtype}
@@ -94,6 +94,7 @@ function Row<I extends IDStrings = IDStrings>({
dropdown={
<ScoreDropdown
chart={sc.__related.chart}
song={sc.__related.song}
game={game}
playtype={playtype}
user={sc.__related.user}
@@ -0,0 +1,376 @@
import { APIFetchV1 } from "util/api";
import { clamp } from "util/misc";
import Divider from "components/util/Divider";
import Select from "components/util/Select";
import React, { useEffect, useMemo, useState } from "react";
import { Button, Col, Form, Modal, Row } from "react-bootstrap";
import {
ChartDocument,
FolderDocument,
FormatChart,
Game,
GetGamePTConfig,
GoalDocument,
Playtype,
SongDocument,
} from "tachi-common";
import { GamePT, SetState, UGPT } from "types/react";
export default function SetNewGoalModal({
show,
setShow,
game,
playtype,
reqUser,
preData,
onNewGoalSet,
}: {
show: boolean;
setShow: SetState<boolean>;
onNewGoalSet?: () => void;
preData: { chart: ChartDocument; song: SongDocument } | FolderDocument;
} & UGPT) {
const gptConfig = GetGamePTConfig(game, playtype);
const [criteria, setCriteria] = useState<GoalDocument["criteria"]>({
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<GoalDocument["charts"]>(
() =>
"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 (
<Modal size="xl" show={show} onHide={() => setShow(false)}>
<Modal.Header closeButton>
<Modal.Title>Set Goal for {identifier}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Row>
{/* <Col xs={12}>
<RenderGoalChartSet charts={charts} identifier={identifier} />
</Col> */}
<Col xs={12}>
<RenderGoalCriteriaPicker
criteria={criteria}
charts={charts}
setCriteria={setCriteria}
game={game}
playtype={playtype}
/>
<Divider />
</Col>
<Col xs={12} className="w-100 d-flex justify-content-center">
<Button
variant="primary"
onClick={() => {
APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}/targets/goals/add-goal`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
criteria,
charts,
}),
},
true,
true
).then((r) => {
if (r.success) {
setShow(false);
onNewGoalSet?.();
}
});
}}
disabled={criteria.mode === "absolute" && criteria.countNum <= 1}
>
Set Goal!
</Button>
</Col>
</Row>
</Modal.Body>
</Modal>
);
}
function RenderGoalChartSet({
charts,
identifier,
}: {
charts: GoalDocument["charts"];
identifier: string;
}) {
switch (charts.type) {
case "any":
return <div>Any Chart</div>;
case "single":
return <div>Selected Chart: {identifier}</div>;
case "folder":
return <div>Selected Folder: {identifier}</div>;
case "multi":
return <div>Selected Charts: {identifier}</div>;
}
}
function RenderGoalCriteriaPicker({
criteria,
charts,
setCriteria,
game,
playtype,
}: {
criteria: GoalDocument["criteria"];
setCriteria: SetState<GoalDocument["criteria"]>;
charts: GoalDocument["charts"];
game: Game;
playtype: Playtype;
}) {
return (
<>
<div>
<Select
inline
value={criteria.key}
setValue={(key) => {
const baseKeyValue = getBaseKeyValue(game, playtype, key);
setCriteria({
...criteria,
key,
value: baseKeyValue,
});
}}
>
<option value="scoreData.score">Score</option>
<option value="scoreData.percent">Percent</option>
<option value="scoreData.gradeIndex">Grade</option>
<option value="scoreData.lampIndex">Lamp</option>
</Select>
is greater than or equal to
<div className="form-group" style={{ display: "inline" }}>
<CriteriaValuePicker
criteria={criteria}
game={game}
playtype={playtype}
onChange={(value) =>
setCriteria({
...criteria,
value,
})
}
/>
</div>
</div>
{charts.type !== "single" && (
<>
<Divider />
<CriteriaModePicker
criteria={criteria}
game={game}
playtype={playtype}
onChange={(mode, countNum) => {
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 (
<>
<div
className={`my-4 ${criteria.mode !== "single" ? "text-muted" : ""}`}
style={{ fontWeight: criteria.mode === "single" ? "bold" : "" }}
>
<Form.Check
type="radio"
style={{ display: "inline" }}
className="mr-4"
checked={criteria.mode === "single"}
onClick={() => onChange("single")}
/>{" "}
On any chart in this folder
</div>
<div
className={`my-4 ${criteria.mode !== "absolute" ? "text-muted" : ""}`}
style={{ fontWeight: criteria.mode === "absolute" ? "bold" : "" }}
>
<Form.Check
type="radio"
style={{ display: "inline" }}
className="mr-4"
checked={criteria.mode === "absolute"}
onClick={() => onChange("absolute", Number(absCountNum))}
/>{" "}
On{" "}
<Form.Control
style={{ display: "inline", width: "unset" }}
className="mx-2"
onChange={(e) => setAbsCountNum(Number(e.target.value))}
type="number"
min={0}
value={absCountNum}
/>{" "}
{absCountNum === 1 ? "chart" : "charts"} in this folder
</div>
<div
className={`my-4 ${criteria.mode !== "proportion" ? "text-muted" : ""}`}
style={{ fontWeight: criteria.mode === "proportion" ? "bold" : "" }}
>
<Form.Check
type="radio"
style={{ display: "inline" }}
className="mr-4"
checked={criteria.mode === "proportion"}
onClick={() => onChange("proportion", perCountNum / 100)}
/>{" "}
On
<Form.Control
style={{ display: "inline", width: "unset" }}
className="mx-2"
onChange={(e) => setPerCountNum(clamp(Number(e.target.value), 0, 100))}
type="number"
min={0}
max={100}
value={perCountNum}
/>
% of charts in this folder
</div>
</>
);
}
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 (
<Form.Control
style={{ display: "inline", width: "unset" }}
className="mx-2"
onChange={(e) => onChange(Number(e.target.value))}
type="number"
min={0}
value={criteria.value}
/>
);
case "scoreData.percent":
return (
<Form.Control
style={{ display: "inline", width: "unset" }}
className="mx-2"
onChange={(e) => onChange(Number(e.target.value))}
type="number"
value={criteria.value}
min={0}
max={gptConfig.percentMax}
/>
);
case "scoreData.gradeIndex":
return (
<Select
inline
value={criteria.value.toString()}
setValue={(v) => onChange(Number(v))}
>
{gptConfig.grades.map((e, i) => (
<option key={i} value={i}>
{e}
</option>
))}
</Select>
);
case "scoreData.lampIndex":
return (
<Select
inline
value={criteria.value.toString()}
setValue={(v) => onChange(Number(v))}
>
{gptConfig.lamps.map((e, i) => (
<option key={i} value={i}>
{e}
</option>
))}
</Select>
);
}
}
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;
}
}
@@ -5,7 +5,8 @@ import { SuccessfulAPIResponse } from "tachi-common";
export default function useApiQuery<T>(
url: string | string[],
options?: RequestInit,
additionalDeps?: string[]
additionalDeps?: string[],
skip = false
) {
const deps = [];
@@ -22,6 +23,10 @@ export default function useApiQuery<T>(
return useQuery<T, UnsuccessfulAPIFetchResponse>(
deps,
async () => {
if (skip) {
throw new Error("Skipped");
}
if (Array.isArray(url)) {
const results = await Promise.all(url.map((u) => APIFetchV1(u, options)));
+11
View File
@@ -25,6 +25,8 @@ import {
UGSRatingsLookup,
UserGameStats,
UserGameStatsSnapshot,
QuestDocument,
QuestSubscriptionDocument,
} from "tachi-common";
export interface UGPTStatsReturn<I extends IDStrings = IDStrings> {
@@ -231,3 +233,12 @@ export interface ActivityReturn {
}
export type RecordActivityReturn = Partial<Record<IDStrings, ActivityReturn>>;
export interface GoalsOnChartReturn {
goals: Array<GoalDocument>;
goalSubs: Array<GoalSubscriptionDocument>;
quests: Array<QuestDocument>;
questSubs: Array<QuestSubscriptionDocument>;
}
export type GoalsOnFolderReturn = GoalsOnChartReturn;
+11
View File
@@ -10,6 +10,9 @@ import {
ScoreDocument,
SongDocument,
UserGameStats,
GoalSubscriptionDocument,
GoalDocument,
QuestDocument,
} from "tachi-common";
export type PBDataset<I extends IDStrings = IDStrings> = (PBScoreDocument<I> & {
@@ -81,3 +84,11 @@ export type FailedImportDataset = Array<
};
}
>;
export type GoalSubDataset = (GoalSubscriptionDocument & {
__related: {
user: UserDocument;
goal: GoalDocument;
parentQuests: Array<QuestDocument>;
};
})[];
+52
View File
@@ -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<integer, UserDocument>
) {
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;
}
+23
View File
@@ -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<JSX.Element>, 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<QuestDocument>) {
const map = new Map<string, QuestDocument>();
for (const q of quests) {
map.set(q.questID, q);
}
return map;
}
+8 -8
View File
@@ -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<G extends Game = Game> {
@@ -74,7 +74,7 @@ interface BaseGamePTConfig<I extends IDStrings> {
grades: Array<Grades[I]>;
gradeColours: Record<Grades[I], string>;
clearGrade: Grades[I];
gradeBoundaries: Array<number> | null;
gradeBoundaries: Array<number>;
lamps: Array<Lamps[I]>;
lampColours: Record<Lamps[I], string>;
@@ -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: {
@@ -25,12 +25,6 @@ export function GetGradeFromPercent<I extends IDStrings = IDStrings>(
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])) {
@@ -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<string>,
logger: KtLogger
): Promise<{ goals: Array<GoalDocument>; goalSubsMap: Map<string, GoalSubscriptionDocument> }> {
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<string> = [];
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<string, GoalSubscriptionDocument> = 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<string>, chartIDsArr: Array<string>) {
// 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<Array<GoalDocument>> = 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;
}
+149 -7
View File
@@ -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<Array<QuestSubscriptionDocument & { quest: QuestDocument }>> {
const blockers: Array<QuestSubscriptionDocument & { quest: QuestDocument }> = await db[
const parents: Array<QuestSubscriptionDocument & { quest: QuestDocument }> = 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<string>,
logger: KtLogger,
onlyUnachieved = false,
excludeAny = false
): Promise<{ goals: Array<GoalDocument>; goalSubsMap: Map<string, GoalSubscriptionDocument> }> {
const gsQuery: FilterQuery<GoalSubscriptionDocument> = {
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<string> = [];
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<string, GoalSubscriptionDocument> = 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<string>, chartIDsArr: Array<string>) {
// 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<Array<GoalDocument>> = 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;
}
+35
View File
@@ -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<GoalSubscriptionDocument>
) {
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;
}
@@ -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({
@@ -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);