mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-01 03:18:06 +03:00
feat: initial goals and quests
This commit is contained in:
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
)} */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+78
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+15
-2
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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)));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
})[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -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({
|
||||
|
||||
+124
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user