mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-01 03:18:06 +03:00
Merge pull request #754 from TNG-dev/zkldi/rival-activity
This commit is contained in:
Submodule client/src/_assets/metronic-scss updated: d44b4be295...66bb3bb1e2
@@ -1,43 +1,8 @@
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { DEFAULT_BAR_PROPS } from "util/charts";
|
||||
import { CreateChartMap, CreateGoalMap, CreateSongMap, CreateUserMap } from "util/data";
|
||||
import { UppercaseFirst } from "util/misc";
|
||||
import { MillisToSince } from "util/time";
|
||||
import { ResponsiveBar } from "@nivo/bar";
|
||||
import { BarChartTooltip } from "components/charts/ChartTooltip";
|
||||
import ClassBadge from "components/game/ClassBadge";
|
||||
import Activity from "components/activity/Activity";
|
||||
import useSetSubheader from "components/layout/header/useSetSubheader";
|
||||
import Card from "components/layout/page/Card";
|
||||
import GoalSubTable from "components/tables/goals/GoalSubTable";
|
||||
import ScoreTable from "components/tables/scores/ScoreTable";
|
||||
import ApiError from "components/util/ApiError";
|
||||
import AsyncLoader from "components/util/AsyncLoader";
|
||||
import Divider from "components/util/Divider";
|
||||
import Icon from "components/util/Icon";
|
||||
import Loading from "components/util/Loading";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import { ColourConfig, TachiConfig } from "lib/config";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Col, Form, Row } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
ChartDocument,
|
||||
ClassAchievementDocument,
|
||||
FormatGame,
|
||||
gameClasses,
|
||||
GamePTConfig,
|
||||
GetGameConfig,
|
||||
GetGamePTConfig,
|
||||
GoalDocument,
|
||||
integer,
|
||||
UserDocument,
|
||||
ScoreDocument,
|
||||
SongDocument,
|
||||
GoalSubscriptionDocument,
|
||||
} from "tachi-common";
|
||||
import { RecentClassesReturn } from "types/api-returns";
|
||||
import React from "react";
|
||||
import { FormatGame, GetGameConfig } from "tachi-common";
|
||||
import { GamePT } from "types/react";
|
||||
import { ScoreDataset } from "types/tables";
|
||||
|
||||
export default function GPTMainPage({ game, playtype }: GamePT) {
|
||||
useSetSubheader(
|
||||
@@ -46,334 +11,5 @@ export default function GPTMainPage({ game, playtype }: GamePT) {
|
||||
FormatGame(game, playtype)
|
||||
);
|
||||
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card header="Recent Highlighted Scores">
|
||||
<RecentHighlightedScoresComponent game={game} playtype={playtype} />
|
||||
</Card>
|
||||
<Divider />
|
||||
<Card header="Recent Achieved Goals">
|
||||
<RecentAchievedGoalsComponent game={game} playtype={playtype} />
|
||||
</Card>
|
||||
{Object.keys(gptConfig.classHumanisedFormat).length !== 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Card header="Recently Achieved Classes">
|
||||
<RecentClassesComponent game={game} playtype={playtype} />
|
||||
</Card>
|
||||
<Divider />
|
||||
<Card header="Class Distributions">
|
||||
<ClassDistributionComponent game={game} playtype={playtype} />
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentAchievedGoalsComponent({ game, playtype }: GamePT) {
|
||||
const { data, error } = useApiQuery<{
|
||||
users: UserDocument[];
|
||||
goals: GoalDocument[];
|
||||
goalSubs: GoalSubscriptionDocument[];
|
||||
}>(`/games/${game}/${playtype}/goals/recently-achieved`);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const dataset = [];
|
||||
|
||||
const userMap = CreateUserMap(data.users);
|
||||
const goalMap = CreateGoalMap(data.goals);
|
||||
|
||||
for (const userGoal of data.goalSubs) {
|
||||
const user = userMap.get(userGoal.userID);
|
||||
const goal = goalMap.get(userGoal.goalID);
|
||||
|
||||
if (!user || !goal) {
|
||||
console.warn(
|
||||
`Couldn't find goal or user for ${userGoal.userID}, ${userGoal.goalID}? Skipping.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
dataset.push({
|
||||
...userGoal,
|
||||
__related: {
|
||||
user,
|
||||
goal,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col className="text-center" xs={12}>
|
||||
Here's what goals players have recently achieved!
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<Divider />
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<GoalSubTable dataset={dataset} game={game} playtype={playtype} />
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentHighlightedScoresComponent({ game, playtype }: GamePT) {
|
||||
const { data, error } = useApiQuery<{
|
||||
scores: ScoreDocument[];
|
||||
users: UserDocument[];
|
||||
charts: ChartDocument[];
|
||||
songs: SongDocument[];
|
||||
}>(`/games/${game}/${playtype}/scores/highlighted`);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const dataset: ScoreDataset = [];
|
||||
|
||||
const songMap = CreateSongMap(data.songs);
|
||||
const chartMap = CreateChartMap(data.charts);
|
||||
const userMap = CreateUserMap(data.users);
|
||||
|
||||
for (const [index, score] of data.scores.entries()) {
|
||||
dataset.push({
|
||||
...score,
|
||||
__related: {
|
||||
chart: chartMap.get(score.chartID)!,
|
||||
song: songMap.get(score.songID)!,
|
||||
index,
|
||||
user: userMap.get(score.userID)!,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col className="text-center" xs={12}>
|
||||
Here's what players have recently marked as highlighted scores!
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<Divider />
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<ScoreTable userCol dataset={dataset} game={game} playtype={playtype} />
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentClassesComponent({ game, playtype }: GamePT) {
|
||||
const { data, error } = useApiQuery<RecentClassesReturn>(
|
||||
`/games/${game}/${playtype}/recent-classes`
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
// if (data.classes.length === 0) {
|
||||
// return <div className="text-center">Looks like nobodies got anything here... yet!</div>;
|
||||
// }
|
||||
|
||||
const userMap = new Map<integer, UserDocument>();
|
||||
|
||||
for (const user of data.users) {
|
||||
userMap.set(user.id, user);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.classes.length === 0 ? (
|
||||
<span>Looks like nobodies ever got a class for this game. Ah well.</span>
|
||||
) : (
|
||||
data.classes.map((c, i) => {
|
||||
const user = userMap.get(c.userID)!;
|
||||
|
||||
return <AchievedClassRow key={i} classInfo={c} user={user} />;
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AchievedClassRow({
|
||||
classInfo,
|
||||
user,
|
||||
}: {
|
||||
classInfo: ClassAchievementDocument;
|
||||
user: UserDocument;
|
||||
}) {
|
||||
const game = classInfo.game;
|
||||
const playtype = classInfo.playtype;
|
||||
|
||||
return (
|
||||
<div className="py-2 timeline-element">
|
||||
<Link to={`/dashboard/users/${user.username}/games/${game}/${playtype}`}>
|
||||
{user.username}
|
||||
</Link>{" "}
|
||||
achieved{" "}
|
||||
<ClassBadge
|
||||
classSet={classInfo.classSet}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
classValue={classInfo.classValue}
|
||||
/>
|
||||
{classInfo.classOldValue !== null && (
|
||||
<>
|
||||
{" "}
|
||||
(Raised from{" "}
|
||||
<ClassBadge
|
||||
classSet={classInfo.classSet}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
classValue={classInfo.classOldValue}
|
||||
/>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
<span className="float-right d-none d-lg-inline">
|
||||
<Icon type="clock" />
|
||||
|
||||
<span className="pl-1">{MillisToSince(classInfo.timeAchieved)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassDistributionComponent({ game, playtype }: GamePT) {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const [className, setClassName] = useState<gameClasses.AllClassSets>(
|
||||
Object.keys(gptConfig.classHumanisedFormat)[0] as gameClasses.AllClassSets
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
setClassName(Object.keys(gptConfig.classHumanisedFormat)[0] as gameClasses.AllClassSets);
|
||||
}, [game, playtype]);
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
This will show the distribution of classes among all the players on{" "}
|
||||
{TachiConfig.name}!
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<Divider />
|
||||
</Col>
|
||||
<Col xs={12} lg={4} className="offset-lg-4">
|
||||
<Form.Control
|
||||
as="select"
|
||||
value={className}
|
||||
onChange={(e) => setClassName(e.target.value as gameClasses.AllClassSets)}
|
||||
>
|
||||
{Object.keys(gptConfig.classHumanisedFormat).map((e) => (
|
||||
<option value={e} key={e}>
|
||||
{UppercaseFirst(e)}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<Divider />
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<AsyncLoader
|
||||
promiseFn={async () => {
|
||||
const res = await APIFetchV1<Record<string, integer>>(
|
||||
`/games/${game}/${playtype}/class-distribution?class=${className}`
|
||||
);
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.description);
|
||||
}
|
||||
|
||||
return res.body;
|
||||
}}
|
||||
>
|
||||
{(d) => <ClassDistChart data={d} gptConfig={gptConfig} className={className} />}
|
||||
</AsyncLoader>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassDistChart({
|
||||
data,
|
||||
gptConfig,
|
||||
className,
|
||||
}: {
|
||||
data: Record<string, integer>;
|
||||
gptConfig: GamePTConfig;
|
||||
className: gameClasses.AllClassSets;
|
||||
}) {
|
||||
const dataset = [];
|
||||
|
||||
for (const key in gptConfig.classHumanisedFormat[className]) {
|
||||
dataset.push({
|
||||
id: key,
|
||||
count: data[key] ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (dataset.length === 0) {
|
||||
return (
|
||||
<div style={{ height: 400 }}>
|
||||
<div className="d-flex justify-content-center align-items-center h-100">
|
||||
<span>Looks like nobody has any of these classes yet.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: 200 + dataset.length * 10 }}>
|
||||
<ResponsiveBar
|
||||
data={dataset}
|
||||
keys={["count"]}
|
||||
indexBy="id"
|
||||
layout="horizontal"
|
||||
margin={{ left: 80, bottom: 40, top: 20, right: 20 }}
|
||||
colors={() => ColourConfig.primary}
|
||||
axisLeft={{
|
||||
format: (v) => gptConfig.classHumanisedFormat[className][v]?.display,
|
||||
}}
|
||||
tooltip={(d) => (
|
||||
<BarChartTooltip
|
||||
point={d}
|
||||
renderFn={(d) => (
|
||||
<div className="w-100 text-center">
|
||||
{
|
||||
gptConfig.classHumanisedFormat[className][
|
||||
d.indexValue as number
|
||||
].display
|
||||
}
|
||||
: {(d.data as any).count}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{...DEFAULT_BAR_PROPS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return <Activity url={`/games/${game}/${playtype}/activity`} />;
|
||||
}
|
||||
|
||||
@@ -24,18 +24,14 @@ import {
|
||||
} from "tachi-common";
|
||||
import { GameClassSets } from "tachi-common/game-classes";
|
||||
import { GPTLeaderboard, UGPTLeaderboardAdjacent } from "types/api-returns";
|
||||
import { GamePT, SetState } from "types/react";
|
||||
import { GamePT, SetState, UGPT } from "types/react";
|
||||
|
||||
interface LeaderboardsData {
|
||||
stats: UGPTLeaderboardAdjacent;
|
||||
leaderboard: GPTLeaderboard;
|
||||
}
|
||||
|
||||
export default function LeaderboardsPage({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
}: { reqUser: UserDocument } & GamePT) {
|
||||
export default function LeaderboardsPage({ reqUser, game, playtype }: UGPT) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Leaderboard"],
|
||||
|
||||
@@ -1,47 +1,27 @@
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { ONE_HOUR, ONE_MINUTE } from "util/constants/time";
|
||||
import { CreateChartMap, CreateSongMap, GetPBs } from "util/data";
|
||||
import { ClumpActivity } from "util/activity";
|
||||
import { UppercaseFirst } from "util/misc";
|
||||
import { NumericSOV } from "util/sorts";
|
||||
import { FormatDate, FormatDuration, FormatTime, MillisToSince } from "util/time";
|
||||
import { FormatDate, MillisToSince } from "util/time";
|
||||
import TimelineChart from "components/charts/TimelineChart";
|
||||
import useSetSubheader from "components/layout/header/useSetSubheader";
|
||||
import Card from "components/layout/page/Card";
|
||||
import MiniTable from "components/tables/components/MiniTable";
|
||||
import ScoreTable from "components/tables/scores/ScoreTable";
|
||||
import Activity from "components/activity/Activity";
|
||||
import UGPTStatShowcase from "components/user/UGPTStatShowcase";
|
||||
import AsyncLoader from "components/util/AsyncLoader";
|
||||
import ApiError from "components/util/ApiError";
|
||||
import Divider from "components/util/Divider";
|
||||
import Icon from "components/util/Icon";
|
||||
import Loading from "components/util/Loading";
|
||||
import Muted from "components/util/Muted";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import Select from "components/util/Select";
|
||||
import SelectButton from "components/util/SelectButton";
|
||||
import { useProfileRatingAlg } from "components/util/useScoreRatingAlg";
|
||||
import { DateTime } from "luxon";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Badge, Row } from "react-bootstrap";
|
||||
import {
|
||||
FormatGame,
|
||||
GetGameConfig,
|
||||
GetGamePTConfig,
|
||||
UserDocument,
|
||||
ScoreDocument,
|
||||
SessionDocument,
|
||||
UserGameStats,
|
||||
} from "tachi-common";
|
||||
import { SessionReturns, UGPTHistory } from "types/api-returns";
|
||||
import { GamePT, SetState } from "types/react";
|
||||
import { ScoreDataset } from "types/tables";
|
||||
import SessionRaiseBreakdown from "components/sessions/SessionRaiseBreakdown";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import ApiError from "components/util/ApiError";
|
||||
import Loading from "components/util/Loading";
|
||||
import Select from "components/util/Select";
|
||||
import { FormatGame, GetGameConfig, GetGamePTConfig, UserGameStats } from "tachi-common";
|
||||
import { UGPTHistory } from "types/api-returns";
|
||||
import { GamePT, SetState, UGPT } from "types/react";
|
||||
|
||||
export default function OverviewPage({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
}: { reqUser: UserDocument } & GamePT) {
|
||||
export default function OverviewPage({ reqUser, game, playtype }: UGPT) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
useSetSubheader(
|
||||
["Users", reqUser.username, "Games", gameConfig.name, playtype],
|
||||
@@ -53,209 +33,25 @@ export default function OverviewPage({
|
||||
<React.Fragment key={`${game}:${playtype}`}>
|
||||
<UGPTStatShowcase reqUser={reqUser} game={game} playtype={playtype} />
|
||||
<RankingInfo reqUser={reqUser} game={game} playtype={playtype} />
|
||||
<LastSession reqUser={reqUser} game={game} playtype={playtype} />
|
||||
<RecentActivity reqUser={reqUser} game={game} playtype={playtype} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function LastSession({ reqUser, game, playtype }: { reqUser: UserDocument } & GamePT) {
|
||||
function RecentActivity({ reqUser, game, playtype }: UGPT) {
|
||||
return (
|
||||
<AsyncLoader
|
||||
promiseFn={async () => {
|
||||
const res = await APIFetchV1<SessionDocument>(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/sessions/last`
|
||||
);
|
||||
|
||||
if (res.statusCode === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.description);
|
||||
}
|
||||
|
||||
const sessionDataRes = await APIFetchV1<SessionReturns>(
|
||||
`/sessions/${res.body!.sessionID}`
|
||||
);
|
||||
|
||||
if (!sessionDataRes.success) {
|
||||
throw new Error(res.description);
|
||||
}
|
||||
|
||||
return { session: res.body, sessionData: sessionDataRes.body };
|
||||
}}
|
||||
>
|
||||
{(data) =>
|
||||
data && (
|
||||
<Card className="mt-4" header="Most Recent Session">
|
||||
<div className="row d-flex justify-content-center">
|
||||
<div className="col-12 text-center">
|
||||
<h4 className="display-4">{data.session.name}</h4>
|
||||
<span className="text-muted">{data.session.desc}</span>
|
||||
<Divider className="mt-4 mb-8" />
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-lg-3 text-center">
|
||||
<MiniTable className="mt-4" headers={["Information"]} colSpan={2}>
|
||||
<tr>
|
||||
<td>Started</td>
|
||||
<td>{FormatTime(data.session.timeStarted)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{/* If session ended less than 2 hours ago, it can still be appended to. */}
|
||||
{/* Kinda - time is a bit nonlinear wrt. importing scores, as scores don't have to have */}
|
||||
{/* happened at the same time they were imported. */}
|
||||
{/* that doesn't matter though - this is just a UI thing */}
|
||||
<td>
|
||||
{Date.now() - data.session.timeEnded < ONE_HOUR * 2
|
||||
? "Last Score"
|
||||
: "Ended At"}
|
||||
</td>
|
||||
<td>
|
||||
{FormatTime(data.session.timeEnded)}
|
||||
{/* if the last score was less than 10 mins ago, this session is probably still being populated */}
|
||||
{Date.now() - data.session.timeEnded <
|
||||
ONE_MINUTE * 10 && (
|
||||
<>
|
||||
<br />
|
||||
<Badge variant="primary" className="mt-2">
|
||||
Ongoing!
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Duration</td>
|
||||
<td>
|
||||
{FormatDuration(
|
||||
data.session.timeEnded - data.session.timeStarted
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Scores</td>
|
||||
<td>{data.session.scoreInfo.length}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>PBs</td>
|
||||
<td>{GetPBs(data.session.scoreInfo).length}</td>
|
||||
</tr>
|
||||
</MiniTable>
|
||||
</div>
|
||||
<div className="col-12 col-lg-9">
|
||||
<Row>
|
||||
<SessionRaiseBreakdown
|
||||
sessionData={data.sessionData}
|
||||
setScores={() => void 0}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<Divider />
|
||||
<RecentSessionScoreInfo
|
||||
session={data.session}
|
||||
sessionData={data.sessionData}
|
||||
reqUser={reqUser}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
</AsyncLoader>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentSessionScoreInfo({
|
||||
session,
|
||||
sessionData,
|
||||
reqUser,
|
||||
}: {
|
||||
session: SessionDocument;
|
||||
sessionData: SessionReturns;
|
||||
reqUser: UserDocument;
|
||||
}) {
|
||||
const { game, playtype } = session;
|
||||
|
||||
const highlightedScores = sessionData.scores.filter((e) => e.highlight);
|
||||
|
||||
const [mode, setMode] = useState<"highlight" | "best" | "recent">(
|
||||
highlightedScores.length > 0 ? "highlight" : "best"
|
||||
);
|
||||
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const songMap = CreateSongMap(sessionData.songs);
|
||||
const chartMap = CreateChartMap(sessionData.charts);
|
||||
|
||||
const dataset = useMemo(() => {
|
||||
let scoreSet = sessionData.scores;
|
||||
if (mode === "highlight") {
|
||||
scoreSet = highlightedScores;
|
||||
} else if (mode === "best") {
|
||||
scoreSet.sort((a, b) =>
|
||||
NumericSOV<ScoreDocument>(
|
||||
(d) => d.calculatedData[gptConfig.defaultScoreRatingAlg] ?? 0
|
||||
)(b, a)
|
||||
);
|
||||
} else if (mode === "recent") {
|
||||
// sneaky hack to sort in reverse
|
||||
scoreSet.sort((a, b) => NumericSOV<ScoreDocument>((d) => d.timeAchieved ?? 0)(b, a));
|
||||
}
|
||||
|
||||
const scoreDataset: ScoreDataset = [];
|
||||
|
||||
for (const score of scoreSet) {
|
||||
const chart = chartMap.get(score.chartID)!;
|
||||
|
||||
scoreDataset.push({
|
||||
...score,
|
||||
__related: {
|
||||
chart,
|
||||
song: songMap.get(chart.songID)!,
|
||||
index: 0,
|
||||
user: reqUser,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return scoreDataset;
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-12 d-flex justify-content-center">
|
||||
<div className="btn-group">
|
||||
<SelectButton id="highlight" value={mode} setValue={setMode}>
|
||||
<Icon type="star" />
|
||||
Highlights
|
||||
</SelectButton>
|
||||
<SelectButton id="best" value={mode} setValue={setMode}>
|
||||
<Icon type="sort-amount-up" />
|
||||
Best
|
||||
</SelectButton>
|
||||
<SelectButton id="recent" value={mode} setValue={setMode}>
|
||||
<Icon type="history" />
|
||||
Recent
|
||||
</SelectButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 mt-4">
|
||||
<ScoreTable
|
||||
dataset={dataset as any}
|
||||
pageLen={5}
|
||||
game={game}
|
||||
playtype={playtype as any}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Activity
|
||||
url={`/users/${reqUser.id}/games/${game}/${playtype}/activity`}
|
||||
handleNoActivity={null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RankingDurations = "3mo" | "year";
|
||||
|
||||
function RankingInfo({ reqUser, game, playtype }: { reqUser: UserDocument } & GamePT) {
|
||||
function RankingInfo({ reqUser, game, playtype }: UGPT) {
|
||||
const [duration, setDuration] = useState<RankingDurations>("3mo");
|
||||
|
||||
const { data, error } = useApiQuery<UGPTHistory>(
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
SongDocument,
|
||||
UnsuccessfulAPIResponse,
|
||||
} from "tachi-common";
|
||||
import { GamePT, SetState } from "types/react";
|
||||
import { GamePT, SetState, UGPT } from "types/react";
|
||||
import usePreferredRanking from "components/util/usePreferredRanking";
|
||||
|
||||
export default function ScoresPage({
|
||||
@@ -54,7 +54,7 @@ export default function ScoresPage({
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-12 text-center">
|
||||
<div className="btn-group mb-4">
|
||||
<div className="btn-group d-flex justify-content-center mb-4">
|
||||
<SelectLinkButton to={`${base}/scores`}>
|
||||
<Icon type="trophy" />
|
||||
Best 100 PBs
|
||||
@@ -286,7 +286,7 @@ function PBsSearch({
|
||||
);
|
||||
}
|
||||
|
||||
function ScoresOverview({ reqUser, game, playtype }: { reqUser: UserDocument } & GamePT) {
|
||||
function ScoresOverview({ reqUser, game, playtype }: UGPT) {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data, error } = useFetchScores(
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function SessionsPage({
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-12 text-center">
|
||||
<div className="btn-group mb-4">
|
||||
<div className="btn-group d-flex justify-content-center mb-4">
|
||||
<SelectButton id="best" setValue={setSessionSet} value={sessionSet}>
|
||||
<Icon type="trophy" />
|
||||
Best Sessions
|
||||
|
||||
@@ -15,13 +15,11 @@ import { UserContext } from "context/UserContext";
|
||||
import React, { useContext, useMemo, useState } from "react";
|
||||
import { Badge, Button, Col, Form, Modal, Row } from "react-bootstrap";
|
||||
import { Redirect, useParams } from "react-router-dom";
|
||||
import { GetGameConfig, UserDocument, SessionDocument } from "tachi-common";
|
||||
import { GetGameConfig, SessionDocument } from "tachi-common";
|
||||
import { SessionReturns } from "types/api-returns";
|
||||
import { GamePT, SetState } from "types/react";
|
||||
import { SetState, UGPT } from "types/react";
|
||||
|
||||
type Props = { reqUser: UserDocument } & GamePT;
|
||||
|
||||
export default function SpecificSessionPage({ reqUser, game, playtype }: Props) {
|
||||
export default function SpecificSessionPage({ reqUser, game, playtype }: UGPT) {
|
||||
const { sessionID } = useParams<{ sessionID: string }>();
|
||||
|
||||
const { data, error } = useApiQuery<SessionReturns>(`/sessions/${sessionID}`);
|
||||
@@ -49,7 +47,7 @@ export default function SpecificSessionPage({ reqUser, game, playtype }: Props)
|
||||
return <SessionPage {...{ data, game, playtype, reqUser }} />;
|
||||
}
|
||||
|
||||
function SessionPage({ data, game, playtype }: Props & { data: SessionReturns }) {
|
||||
function SessionPage({ data, game, playtype }: UGPT & { data: SessionReturns }) {
|
||||
const [sessionData, setSessionData] = useState(data);
|
||||
const { session, user, charts, scores, songs } = sessionData;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { ErrorPage } from "app/pages/ErrorPage";
|
||||
import ClassBadge from "components/game/ClassBadge";
|
||||
import useSetSubheader from "components/layout/header/useSetSubheader";
|
||||
import Card from "components/layout/page/Card";
|
||||
@@ -12,8 +13,9 @@ import Muted from "components/util/Muted";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import SelectButton from "components/util/SelectButton";
|
||||
import useQueryString from "components/util/useQueryString";
|
||||
import { UGPTContext } from "context/UGPTContext";
|
||||
import deepmerge from "deepmerge";
|
||||
import { FieldArray, useFormik } from "formik";
|
||||
import { useFormik } from "formik";
|
||||
import { TachiConfig } from "lib/config";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Button, Col, Form, Row } from "react-bootstrap";
|
||||
@@ -22,18 +24,14 @@ import {
|
||||
FormatGame,
|
||||
GetGameConfig,
|
||||
GetGamePTConfig,
|
||||
UserDocument,
|
||||
ShowcaseStatDetails,
|
||||
TableDocument,
|
||||
UGPTSettings,
|
||||
UserDocument,
|
||||
} from "tachi-common";
|
||||
import { GamePT, SetState } from "types/react";
|
||||
import { UGPTContext } from "context/UGPTContext";
|
||||
import { ErrorPage } from "app/pages/ErrorPage";
|
||||
import { SetState, UGPT } from "types/react";
|
||||
|
||||
type Props = { reqUser: UserDocument } & GamePT;
|
||||
|
||||
export default function UGPTSettingsPage({ reqUser, game, playtype }: Props) {
|
||||
export default function UGPTSettingsPage({ reqUser, game, playtype }: UGPT) {
|
||||
const query = useQueryString();
|
||||
|
||||
const [page, setPage] = useState<"preferences" | "showcase">(
|
||||
@@ -47,7 +45,7 @@ export default function UGPTSettingsPage({ reqUser, game, playtype }: Props) {
|
||||
`${reqUser.username}'s ${FormatGame(game, playtype)} Settings`
|
||||
);
|
||||
|
||||
const props = { reqUser, game, playtype };
|
||||
const UGPT = { reqUser, game, playtype };
|
||||
|
||||
return (
|
||||
<Card header="Settings" className="col-12 offset-lg-2 col-lg-8">
|
||||
@@ -67,9 +65,9 @@ export default function UGPTSettingsPage({ reqUser, game, playtype }: Props) {
|
||||
<div className="col-12">
|
||||
<Divider className="mt-4 mb-4" />
|
||||
{page === "preferences" ? (
|
||||
<PreferencesForm {...props} />
|
||||
<PreferencesForm {...UGPT} />
|
||||
) : (
|
||||
<ShowcaseForm {...props} />
|
||||
<ShowcaseForm {...UGPT} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +75,7 @@ export default function UGPTSettingsPage({ reqUser, game, playtype }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function PreferencesForm({ reqUser, game, playtype }: Props) {
|
||||
function PreferencesForm({ reqUser, game, playtype }: UGPT) {
|
||||
const { loggedInData, setLoggedInData } = useContext(UGPTContext);
|
||||
|
||||
if (!loggedInData) {
|
||||
@@ -386,7 +384,7 @@ function PreferencesForm({ reqUser, game, playtype }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function ShowcaseForm({ reqUser, game, playtype }: Props) {
|
||||
function ShowcaseForm({ reqUser, game, playtype }: UGPT) {
|
||||
const { loggedInData, setLoggedInData } = useContext(UGPTContext);
|
||||
|
||||
if (!loggedInData) {
|
||||
@@ -468,7 +466,7 @@ function RenderCurrentStats({
|
||||
}: {
|
||||
stats: ShowcaseStatDetails[];
|
||||
setStats: SetState<ShowcaseStatDetails[]>;
|
||||
} & Props) {
|
||||
} & UGPT) {
|
||||
function RemoveStatAtIndex(index: number) {
|
||||
setStats(stats.filter((e, i) => i !== index));
|
||||
}
|
||||
|
||||
+3
-5
@@ -15,11 +15,9 @@ import { UserContext } from "context/UserContext";
|
||||
import React, { useContext, useMemo, useState } from "react";
|
||||
import { FolderDocument, GetGamePTConfig, UserDocument } from "tachi-common";
|
||||
import { FolderStatsInfo, UGPTFolderSearch } from "types/api-returns";
|
||||
import { GamePT } from "types/react";
|
||||
import { GamePT, UGPT } from "types/react";
|
||||
|
||||
type Props = { reqUser: UserDocument } & GamePT;
|
||||
|
||||
export default function FoldersSearch({ reqUser, game, playtype }: Props) {
|
||||
export default function FoldersSearch({ reqUser, game, playtype }: UGPT) {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const params = useMemo(() => new URLSearchParams({ search }), [search]);
|
||||
@@ -82,7 +80,7 @@ export function FolderInfoComponent({
|
||||
playtype,
|
||||
folderStats,
|
||||
folder,
|
||||
}: Props & { folder: FolderDocument; folderStats: FolderStatsInfo }) {
|
||||
}: UGPT & { folder: FolderDocument; folderStats: FolderStatsInfo }) {
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const scoreBucket = useBucket(game, playtype);
|
||||
|
||||
+1
-1
@@ -27,9 +27,9 @@ import {
|
||||
GetGamePTConfig,
|
||||
UserDocument,
|
||||
TableDocument,
|
||||
Playtype,
|
||||
} from "tachi-common";
|
||||
import { FolderStatsInfo, UGPTTableReturns } from "types/api-returns";
|
||||
import { Playtype } from "types/tachi";
|
||||
|
||||
interface Props {
|
||||
reqUser: UserDocument;
|
||||
|
||||
+5
-8
@@ -3,20 +3,18 @@ import Divider from "components/util/Divider";
|
||||
import Icon from "components/util/Icon";
|
||||
import SelectLinkButton from "components/util/SelectLinkButton";
|
||||
import useUGPTBase from "components/util/useUGPTBase";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import { AllLUGPTStatsContext } from "context/AllLUGPTStatsContext";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import React, { useContext } from "react";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { FormatGame, GetGameConfig, UserDocument } from "tachi-common";
|
||||
import { GamePT } from "types/react";
|
||||
import { FormatGame, GetGameConfig } from "tachi-common";
|
||||
import { UGPT } from "types/react";
|
||||
import FolderSelectPage from "./FolderSelectPage";
|
||||
import FolderTablePage from "./FolderTablePage";
|
||||
import RecentFoldersPage from "./RecentFoldersPage";
|
||||
import SpecificFolderPage from "./SpecificFolderPage";
|
||||
|
||||
type Props = { reqUser: UserDocument } & GamePT;
|
||||
|
||||
export default function FoldersMainPage({ reqUser, game, playtype }: Props) {
|
||||
export default function FoldersMainPage({ reqUser, game, playtype }: UGPT) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
|
||||
const { user } = useContext(UserContext);
|
||||
@@ -33,7 +31,7 @@ export default function FoldersMainPage({ reqUser, game, playtype }: Props) {
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-12 text-center">
|
||||
<div className="btn-group">
|
||||
<div className="btn-group d-flex justify-content-center mb-8">
|
||||
{user && ugs?.find((x) => x.game === game && x.playtype === playtype) && (
|
||||
<SelectLinkButton to={`${base}/folders/recent`}>
|
||||
<Icon type="clock" />
|
||||
@@ -52,7 +50,6 @@ export default function FoldersMainPage({ reqUser, game, playtype }: Props) {
|
||||
Folder Select
|
||||
</SelectLinkButton>
|
||||
</div>
|
||||
<Divider />
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<Switch>
|
||||
|
||||
+3
-7
@@ -5,16 +5,12 @@ import { UserContext } from "context/UserContext";
|
||||
import React, { useContext } from "react";
|
||||
import { Row } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FolderDocument, UserDocument, RecentlyViewedFolderDocument } from "tachi-common";
|
||||
import { FolderDocument, RecentlyViewedFolderDocument } from "tachi-common";
|
||||
import { FolderStatsInfo } from "types/api-returns";
|
||||
import { GamePT } from "types/react";
|
||||
import { UGPT } from "types/react";
|
||||
import { FolderInfoComponent } from "./FolderSelectPage";
|
||||
|
||||
export default function RecentFoldersPage({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
}: { reqUser: UserDocument } & GamePT) {
|
||||
export default function RecentFoldersPage({ reqUser, game, playtype }: UGPT) {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
if (!user) {
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ import {
|
||||
} from "tachi-common";
|
||||
import { UGPTFolderReturns } from "types/api-returns";
|
||||
import { FolderDataset } from "types/tables";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
import FolderComparePage from "./FolderComparePage";
|
||||
|
||||
interface Props {
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import Activity from "components/activity/Activity";
|
||||
import React from "react";
|
||||
import { UGPT } from "types/react";
|
||||
|
||||
export default function RivalsActivityPage({ reqUser, game, playtype }: UGPT) {
|
||||
return <Activity url={`/users/${reqUser.id}/games/${game}/${playtype}/rivals/activity`} />;
|
||||
}
|
||||
+4
-3
@@ -9,6 +9,7 @@ import React from "react";
|
||||
import { Col, Row } from "react-bootstrap";
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
import { FormatGame, Game, GetGameConfig, Playtype, UserDocument } from "tachi-common";
|
||||
import RivalsActivityPage from "./RivalsActivityPage";
|
||||
import RivalsManagePage from "./RivalsManagePage";
|
||||
|
||||
export default function RivalsMainPage({
|
||||
@@ -39,14 +40,14 @@ export default function RivalsMainPage({
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={12} className="text-center">
|
||||
<div className="btn-group">
|
||||
<div className="btn-group d-flex justify-content-center">
|
||||
<SelectLinkButton to={`${base}/rivals/pb-leaderboard`}>
|
||||
<Icon type="sort-amount-up" />
|
||||
Rival's Bests
|
||||
</SelectLinkButton>
|
||||
<SelectLinkButton to={`${base}/rivals`}>
|
||||
<Icon type="list" />
|
||||
Activity
|
||||
Rival Activity
|
||||
</SelectLinkButton>
|
||||
<SelectLinkButton to={`${base}/rivals/tracking`}>
|
||||
<Icon type="thumbtack" />
|
||||
@@ -63,7 +64,7 @@ export default function RivalsMainPage({
|
||||
<Switch>
|
||||
<Route exact path="/dashboard/users/:userID/games/:game/:playtype/rivals">
|
||||
{settings?.rivals.length === 0 && <Redirect to={`${base}/rivals/manage`} />}
|
||||
NOT WRITTEN YET
|
||||
<RivalsActivityPage reqUser={reqUser} game={game} playtype={playtype} />
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
|
||||
+55
-44
@@ -1,5 +1,6 @@
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { SendErrorToast } from "util/toaster";
|
||||
import { ChangeOpacity } from "util/color-opacity";
|
||||
import Card from "components/layout/page/Card";
|
||||
import ApiError from "components/util/ApiError";
|
||||
import Divider from "components/util/Divider";
|
||||
@@ -11,8 +12,8 @@ import useApiQuery from "components/util/query/useApiQuery";
|
||||
import UserIcon from "components/util/UserIcon";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Button, Col } from "react-bootstrap";
|
||||
import { FormatGame, Game, GetGameConfig, Playtype, UserDocument } from "tachi-common";
|
||||
import { Alert, Button, Col } from "react-bootstrap";
|
||||
import { COLOUR_SET, FormatGame, Game, GetGameConfig, Playtype, UserDocument } from "tachi-common";
|
||||
import useLUGPTSettings from "components/util/useLUGPTSettings";
|
||||
import useSetSubheader from "components/layout/header/useSetSubheader";
|
||||
|
||||
@@ -105,6 +106,57 @@ function RivalsOverviewPage({
|
||||
|
||||
return (
|
||||
<>
|
||||
{isRequestingUser && currentRivals.toString() !== rivals.toString() && (
|
||||
<>
|
||||
{/* kind of a stupid way to check whether the array has changed or not, but who cares. */}
|
||||
<Col xs={12}>
|
||||
<Divider />
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<Alert
|
||||
className="w-100 d-flex justify-content-center"
|
||||
style={{
|
||||
backgroundColor: ChangeOpacity(COLOUR_SET.vibrantYellow, 0.2),
|
||||
flexDirection: "column",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "white" }}>You have unsaved changes!</div>
|
||||
<Divider />
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={async () => {
|
||||
const res = await APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/rivals`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
rivalIDs: rivals.map((e) => e.id),
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
if (res.success) {
|
||||
setCurrentRivals(rivals);
|
||||
setSettings({
|
||||
...settings,
|
||||
rivals: rivals.map((e) => e.id),
|
||||
});
|
||||
}
|
||||
}}
|
||||
variant="primary"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Alert>
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
<Card header={`${isRequestingUser ? "Your" : `${reqUser.username}'s`} Rivals`}>
|
||||
<Col xs={12} className="d-flex justify-content-center flex-wrap">
|
||||
{rivals.map((e) => (
|
||||
@@ -144,47 +196,6 @@ function RivalsOverviewPage({
|
||||
)}
|
||||
</Col>
|
||||
|
||||
{/* kind of a stupid way to check whether the array has changed or not, but who cares. */}
|
||||
{currentRivals.toString() !== rivals.toString() && (
|
||||
<>
|
||||
<Col xs={12}>
|
||||
<Divider />
|
||||
</Col>
|
||||
<Col xs={12} className="d-flex justify-content-center">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={async () => {
|
||||
const res = await APIFetchV1(
|
||||
`/users/${reqUser.id}/games/${game}/${playtype}/rivals`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
rivalIDs: rivals.map((e) => e.id),
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
if (res.success) {
|
||||
setCurrentRivals(rivals);
|
||||
setSettings({
|
||||
...settings,
|
||||
rivals: rivals.map((e) => e.id),
|
||||
});
|
||||
}
|
||||
}}
|
||||
variant="primary"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
|
||||
<UserSelectModal
|
||||
callback={(user) => {
|
||||
if (rivals.length >= 5) {
|
||||
@@ -202,7 +213,7 @@ function RivalsOverviewPage({
|
||||
setShow={setShow}
|
||||
url={`/games/${game}/${playtype}/players`}
|
||||
excludeSet={rivals.map((e) => e.id)}
|
||||
excludeMsg="Already Rivaled!"
|
||||
excludeMsg="Added!"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,13 +6,11 @@ import useUGPTBase from "components/util/useUGPTBase";
|
||||
import React from "react";
|
||||
import { Col, Row } from "react-bootstrap";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import { FormatGame, GetGameConfig, UserDocument } from "tachi-common";
|
||||
import { GamePT } from "types/react";
|
||||
import { FormatGame, GetGameConfig } from "tachi-common";
|
||||
import { UGPT } from "types/react";
|
||||
import GoalsPage from "./GoalsPage";
|
||||
|
||||
type Props = { reqUser: UserDocument } & GamePT;
|
||||
|
||||
export default function TargetsPage({ reqUser, game, playtype }: Props) {
|
||||
export default function TargetsPage({ reqUser, game, playtype }: UGPT) {
|
||||
const gameConfig = GetGameConfig(game);
|
||||
|
||||
useSetSubheader(
|
||||
@@ -26,7 +24,7 @@ export default function TargetsPage({ reqUser, game, playtype }: Props) {
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={12} className="text-center">
|
||||
<div className="btn-group">
|
||||
<div className="btn-group d-flex justify-content-center">
|
||||
<SelectLinkButton to={`${base}/targets/goals`}>
|
||||
<Icon type="bullseye" />
|
||||
Goals
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { CreateUserMap } from "util/data";
|
||||
import { NO_OP, TruncateString } from "util/misc";
|
||||
import { FormatTime, MillisToSince } from "util/time";
|
||||
import { ONE_HOUR } from "util/constants/time";
|
||||
import { ClumpActivity } from "util/activity";
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import SessionRaiseBreakdown from "components/sessions/SessionRaiseBreakdown";
|
||||
import ScoreTable from "components/tables/scores/ScoreTable";
|
||||
import ApiError from "components/util/ApiError";
|
||||
import Divider from "components/util/Divider";
|
||||
import Icon from "components/util/Icon";
|
||||
import LinkButton from "components/util/LinkButton";
|
||||
import Loading from "components/util/Loading";
|
||||
import Muted from "components/util/Muted";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button, Col, Row } from "react-bootstrap";
|
||||
import { FormatChart, UserDocument } from "tachi-common";
|
||||
import { ActivityReturn, SessionReturns } from "types/api-returns";
|
||||
import { ScoreDataset } from "types/tables";
|
||||
import {
|
||||
ClumpedActivity,
|
||||
ClumpedActivityClassAchievement,
|
||||
ClumpedActivityScores,
|
||||
ClumpedActivitySession,
|
||||
} from "types/tachi";
|
||||
import ClassBadge from "components/game/ClassBadge";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
// Records activity for a group of users on a GPT. Also used for single users.
|
||||
export default function Activity({
|
||||
url,
|
||||
handleNoActivity = (
|
||||
<Col xs={12} className="text-center">
|
||||
We found no activity!
|
||||
</Col>
|
||||
),
|
||||
}: {
|
||||
url: string;
|
||||
handleNoActivity?: React.ReactNode;
|
||||
}) {
|
||||
const [clumped, setClumped] = useState<ClumpedActivity>([]);
|
||||
const [users, setUsers] = useState<Array<UserDocument>>([]);
|
||||
|
||||
const { data, error } = useApiQuery<ActivityReturn>(url);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
setClumped([]);
|
||||
setUsers([]);
|
||||
} else {
|
||||
setClumped(ClumpActivity(data));
|
||||
setUsers(data.users);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (clumped.length === 0) {
|
||||
return <>{handleNoActivity}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ActivityInner
|
||||
data={clumped}
|
||||
users={users}
|
||||
fetchMoreFrom={(start) => {
|
||||
APIFetchV1<ActivityReturn>(`${url}?startTime=${start}`).then((r) => {
|
||||
if (r.success) {
|
||||
setUsers([...users, ...r.body.users]);
|
||||
setClumped([...clumped, ...ClumpActivity(r.body)]);
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityInner({
|
||||
data,
|
||||
users,
|
||||
fetchMoreFrom,
|
||||
}: {
|
||||
data: ClumpedActivity;
|
||||
users: Array<UserDocument>;
|
||||
fetchMoreFrom: (start: number) => void;
|
||||
}) {
|
||||
const userMap = CreateUserMap(users);
|
||||
|
||||
return (
|
||||
<Col xs={12} className="text-center">
|
||||
Tip: You can click on an event to learn more about it.
|
||||
<div className="timeline timeline-2 mt-4">
|
||||
<div className="timeline-bar"></div>
|
||||
{data.map((e) => {
|
||||
const user = userMap.get(e.type === "SCORES" ? e.scores[0]?.userID : e.userID);
|
||||
|
||||
if (!user) {
|
||||
return <div>This user doesn't exist? Whoops.</div>;
|
||||
}
|
||||
|
||||
switch (e.type) {
|
||||
case "SCORES":
|
||||
return <ScoresActivity data={e} user={user} />;
|
||||
case "SESSION":
|
||||
return <SessionActivity data={e} user={user} />;
|
||||
case "CLASS_ACHIEVEMENT":
|
||||
return <ClassAchievementActivity data={e} user={user} />;
|
||||
}
|
||||
})}
|
||||
<div className="timeline-item">
|
||||
<div className="timeline-item">
|
||||
<div className="timeline-badge bg-success"></div>
|
||||
<div
|
||||
className="timeline-content d-flex"
|
||||
style={{
|
||||
flexDirection: "column",
|
||||
flexWrap: "wrap",
|
||||
marginRight: "2rem",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
onClick={() => {
|
||||
let lastTimestamp;
|
||||
const lastThing = data.at(-1)!;
|
||||
|
||||
switch (lastThing.type) {
|
||||
case "SCORES":
|
||||
lastTimestamp = lastThing.scores[0]?.timeAchieved;
|
||||
break;
|
||||
case "CLASS_ACHIEVEMENT":
|
||||
lastTimestamp = lastThing.timeAchieved;
|
||||
break;
|
||||
case "SESSION":
|
||||
lastTimestamp = lastThing.timeStarted;
|
||||
}
|
||||
|
||||
if (!lastTimestamp) {
|
||||
alert("Failed. What?");
|
||||
return;
|
||||
}
|
||||
|
||||
fetchMoreFrom(lastTimestamp);
|
||||
}}
|
||||
>
|
||||
Load More...
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoresActivity({ data, user }: { data: ClumpedActivityScores; user: UserDocument }) {
|
||||
const { game, playtype } = data.scores[0];
|
||||
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
let subMessage;
|
||||
let mutedText: string | null | undefined;
|
||||
|
||||
if (data.scores.length === 1) {
|
||||
const score0 = data.scores[0];
|
||||
|
||||
subMessage = `a score on ${FormatChart(
|
||||
score0.game,
|
||||
score0.__related.song,
|
||||
score0.__related.chart
|
||||
)}`;
|
||||
|
||||
if (score0.comment) {
|
||||
mutedText = `"${score0.comment}"`;
|
||||
}
|
||||
} else {
|
||||
subMessage = `${data.scores.length} scores`;
|
||||
|
||||
mutedText = TruncateString(
|
||||
data.scores
|
||||
.map((e) => FormatChart(e.game, e.__related.song, e.__related.chart))
|
||||
.join(", "),
|
||||
100
|
||||
);
|
||||
}
|
||||
|
||||
const dataset: ScoreDataset = data.scores.map((e, i) => ({
|
||||
...e,
|
||||
__related: {
|
||||
...e.__related,
|
||||
index: i,
|
||||
user,
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="timeline-item timeline-hover my-4">
|
||||
<div className="timeline-badge bg-warning"></div>
|
||||
<div
|
||||
className="timeline-content d-flex"
|
||||
style={{
|
||||
flexDirection: "column",
|
||||
flexWrap: "wrap",
|
||||
marginRight: "2rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-between"
|
||||
onClick={() => setShow(!show)}
|
||||
>
|
||||
<div className="mr-3" style={{ width: "70%", textAlign: "left" }}>
|
||||
<Icon
|
||||
type={`chevron-${show ? "down" : "right"}`}
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "1.15rem" }} className="ml-2">
|
||||
{user.username} highlighted {subMessage}!
|
||||
</span>
|
||||
{mutedText && (
|
||||
<>
|
||||
<br />
|
||||
<Muted>{mutedText}</Muted>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: "right" }}>
|
||||
{MillisToSince(data.scores[0].timeAchieved ?? 0)}
|
||||
<br />
|
||||
<span className="text-muted font-italic text-right">
|
||||
{FormatTime(data.scores[0].timeAchieved ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{show && (
|
||||
<>
|
||||
<Divider />
|
||||
<ScoreTable
|
||||
noTopDisplayStr
|
||||
dataset={dataset}
|
||||
game={game}
|
||||
playtype={playtype}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionActivity({ data, user }: { data: ClumpedActivitySession; user: UserDocument }) {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const isProbablyActive = Date.now() - data.timeEnded < ONE_HOUR;
|
||||
|
||||
return (
|
||||
<div className="timeline-item timeline-hover">
|
||||
<div className={`timeline-badge bg-${data.highlight ? "warning" : "secondary"}`}></div>
|
||||
<div
|
||||
className="timeline-content d-flex"
|
||||
style={{
|
||||
flexDirection: "column",
|
||||
flexWrap: "wrap",
|
||||
marginRight: "2rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-between"
|
||||
onClick={() => setShow(!show)}
|
||||
>
|
||||
<div className="mr-3" style={{ width: "70%", textAlign: "left" }}>
|
||||
<Icon
|
||||
type={`chevron-${show ? "down" : "right"}`}
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="ml-2"
|
||||
style={{
|
||||
fontWeight: isProbablyActive ? "bold" : undefined,
|
||||
fontSize: isProbablyActive ? "1.2rem" : undefined,
|
||||
}}
|
||||
>
|
||||
{user.username} {isProbablyActive ? "is having" : "had"} a session '
|
||||
{data.name}' with {data.scoreInfo.length}{" "}
|
||||
{data.scoreInfo.length === 1 ? "score" : "scores"}.
|
||||
</span>
|
||||
<br />
|
||||
{data.desc && data.desc !== "This session has no description." && (
|
||||
<span className="text-muted">{data.desc}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: "right" }} className="mr-1">
|
||||
{MillisToSince(data.timeStarted ?? 0)}
|
||||
<br />
|
||||
<span className="text-muted font-italic text-right">
|
||||
{FormatTime(data.timeStarted ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{show && <SessionShower sessionID={data.sessionID} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionShower({ sessionID }: { sessionID: string }) {
|
||||
const { data, error } = useApiQuery<SessionReturns>(`/sessions/${sessionID}`);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<SessionRaiseBreakdown sessionData={data} setScores={NO_OP} />
|
||||
<Col xs={12}>
|
||||
<Divider />
|
||||
</Col>
|
||||
<div className="d-flex w-100 justify-content-center">
|
||||
<LinkButton
|
||||
className="btn-outline-primary"
|
||||
to={`/dashboard/users/${data.user.username}/games/${data.session.game}/${data.session.playtype}/sessions/${sessionID}`}
|
||||
>
|
||||
View Full Session
|
||||
</LinkButton>
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassAchievementActivity({
|
||||
data,
|
||||
user,
|
||||
}: {
|
||||
data: ClumpedActivityClassAchievement;
|
||||
user: UserDocument;
|
||||
}) {
|
||||
return (
|
||||
<div className="timeline-item timeline-hover">
|
||||
<div className="timeline-badge bg-success"></div>
|
||||
<div
|
||||
className="timeline-content d-flex"
|
||||
style={{
|
||||
flexDirection: "column",
|
||||
flexWrap: "wrap",
|
||||
marginRight: "2rem",
|
||||
}}
|
||||
>
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div className="mr-3" style={{ width: "70%", textAlign: "left" }}>
|
||||
<Link
|
||||
to={`/dashboard/users/${user.username}/games/${data.game}/${data.playtype}`}
|
||||
className="gentle-link"
|
||||
>
|
||||
{user.username}
|
||||
</Link>{" "}
|
||||
achieved{" "}
|
||||
<ClassBadge
|
||||
classSet={data.classSet}
|
||||
game={data.game}
|
||||
playtype={data.playtype}
|
||||
classValue={data.classValue}
|
||||
/>
|
||||
{data.classOldValue !== null && (
|
||||
<>
|
||||
{" "}
|
||||
(Raised from{" "}
|
||||
<ClassBadge
|
||||
classSet={data.classSet}
|
||||
game={data.game}
|
||||
playtype={data.playtype}
|
||||
classValue={data.classOldValue}
|
||||
/>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: "right" }} className="mr-1">
|
||||
{MillisToSince(data.timeAchieved)}
|
||||
<br />
|
||||
<span className="text-muted font-italic text-right">
|
||||
{FormatTime(data.timeAchieved)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,8 @@ export function GPTBottomNav({ baseUrl }: { baseUrl: string }) {
|
||||
const { settings } = useContext(UserSettingsContext);
|
||||
|
||||
const navItems = [
|
||||
<NavItem key="overview" to={`${baseUrl}/`}>
|
||||
Overview
|
||||
<NavItem key="activity" to={`${baseUrl}/`}>
|
||||
Activity
|
||||
</NavItem>,
|
||||
<NavItem key="songs" to={`${baseUrl}/songs`}>
|
||||
Songs
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
UserGameStats,
|
||||
} from "tachi-common";
|
||||
import { ScoreDataset } from "types/tables";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
|
||||
interface Data {
|
||||
import: ImportDocument;
|
||||
|
||||
@@ -8,7 +8,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Game, GetGameConfig, integer, UserDocument, SongDocument } from "tachi-common";
|
||||
import { GamePT, JustChildren } from "types/react";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
import QuickTooltip from "../misc/QuickTooltip";
|
||||
|
||||
interface SearchReturns {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FormatMillions } from "util/misc";
|
||||
import React from "react";
|
||||
import { Game, PBScoreDocument, ScoreDocument } from "tachi-common";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
import ScoreCell from "./ScoreCell";
|
||||
|
||||
export default function ScoreCellLarge(props: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Game } from "tachi-common";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
import { BMSGraphsComponent } from "./components/BMSScoreDropdownParts";
|
||||
import { IIDXGraphsComponent } from "./components/IIDXScoreDropdownParts";
|
||||
import { ITGGraphsComponent } from "./components/ITGScoreDropdownParts";
|
||||
|
||||
@@ -6,7 +6,7 @@ import useScoreRatingAlg from "components/util/useScoreRatingAlg";
|
||||
import React, { useState } from "react";
|
||||
import { Game, IDStrings, ScoreCalculatedDataLookup } from "tachi-common";
|
||||
import { FolderDataset } from "types/tables";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
import DifficultyCell from "../cells/DifficultyCell";
|
||||
import IndicatorsCell from "../cells/IndicatorsCell";
|
||||
import RankingCell, { RankingViewMode } from "../cells/RankingCell";
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
RivalChartDataset,
|
||||
ScoreDataset,
|
||||
} from "types/tables";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
import SelectableRating from "../components/SelectableRating";
|
||||
import { Header, ZTableTHProps } from "../components/TachiTable";
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
ScoreDocument,
|
||||
} from "tachi-common";
|
||||
import { ScoreDataset } from "types/tables";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
import TimestampCell from "../cells/TimestampCell";
|
||||
import TachiTable from "../components/TachiTable";
|
||||
import ScoreCoreCells from "../game-core-cells/ScoreCoreCells";
|
||||
|
||||
@@ -5,7 +5,7 @@ import useScoreRatingAlg from "components/util/useScoreRatingAlg";
|
||||
import React, { useState } from "react";
|
||||
import { Game, IDStrings, ScoreCalculatedDataLookup } from "tachi-common";
|
||||
import { PBDataset } from "types/tables";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
import DropdownIndicatorCell from "../cells/DropdownIndicatorCell";
|
||||
import IndexCell from "../cells/IndexCell";
|
||||
import RankingCell, { RankingViewMode } from "../cells/RankingCell";
|
||||
|
||||
@@ -2,9 +2,8 @@ import { NumericSOV, StrSOV } from "util/sorts";
|
||||
import { CreateDefaultScoreSearchParams } from "util/tables/create-search";
|
||||
import useScoreRatingAlg from "components/util/useScoreRatingAlg";
|
||||
import React, { useState } from "react";
|
||||
import { Game, IDStrings, integer, ScoreCalculatedDataLookup } from "tachi-common";
|
||||
import { Game, IDStrings, integer, ScoreCalculatedDataLookup, Playtype } from "tachi-common";
|
||||
import { ScoreDataset } from "types/tables";
|
||||
import { Playtype } from "types/tachi";
|
||||
import DifficultyCell from "../cells/DifficultyCell";
|
||||
import IndicatorsCell from "../cells/IndicatorsCell";
|
||||
import TimestampCell from "../cells/TimestampCell";
|
||||
@@ -27,6 +26,7 @@ export default function ScoreTable<I extends IDStrings = IDStrings>({
|
||||
userCol = false,
|
||||
game,
|
||||
alg,
|
||||
noTopDisplayStr,
|
||||
}: {
|
||||
dataset: ScoreDataset<I>;
|
||||
pageLen?: integer;
|
||||
@@ -34,6 +34,7 @@ export default function ScoreTable<I extends IDStrings = IDStrings>({
|
||||
userCol?: boolean;
|
||||
game: Game;
|
||||
alg?: ScoreCalculatedDataLookup[I];
|
||||
noTopDisplayStr?: boolean;
|
||||
}) {
|
||||
const defaultRating = useScoreRatingAlg(game, playtype);
|
||||
const [rating, setRating] = useState(alg ?? defaultRating);
|
||||
@@ -53,6 +54,7 @@ export default function ScoreTable<I extends IDStrings = IDStrings>({
|
||||
|
||||
return (
|
||||
<TachiTable
|
||||
noTopDisplayStr={noTopDisplayStr}
|
||||
dataset={dataset}
|
||||
pageLen={pageLen}
|
||||
headers={headers}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SessionCalculatedDataLookup,
|
||||
SessionDocument,
|
||||
} from "tachi-common";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
import IndexCell from "../cells/IndexCell";
|
||||
import SelectableRating from "../components/SelectableRating";
|
||||
import TachiTable, { Header, ZTableTHProps } from "../components/TachiTable";
|
||||
|
||||
@@ -6,7 +6,7 @@ import Divider from "components/util/Divider";
|
||||
import React from "react";
|
||||
import { Game, UserDocument } from "tachi-common";
|
||||
import { UGPTStatsReturn } from "types/api-returns";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
import ProfileBadges from "./ProfileBadges";
|
||||
import ProfilePicture from "./ProfilePicture";
|
||||
import RankingData from "./UGPTRankingData";
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import Loading from "components/util/Loading";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import React, { useContext } from "react";
|
||||
import React from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { integer, UserDocument, ShowcaseStatDetails } from "tachi-common";
|
||||
import { integer, ShowcaseStatDetails } from "tachi-common";
|
||||
import { UGPTPreferenceStatsReturn } from "types/api-returns";
|
||||
import { GamePT } from "types/react";
|
||||
import { UGPT } from "types/react";
|
||||
import { StatDisplay } from "./UGPTStatShowcase";
|
||||
|
||||
type Props = { reqUser: UserDocument } & GamePT;
|
||||
|
||||
export default function UGPTStatContainer({
|
||||
stat,
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
shouldFetchCompareID,
|
||||
}: { stat: ShowcaseStatDetails; shouldFetchCompareID?: integer } & Props) {
|
||||
}: { stat: ShowcaseStatDetails; shouldFetchCompareID?: integer } & UGPT) {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set("mode", stat.mode);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ShowcaseStatDetails,
|
||||
} from "tachi-common";
|
||||
import { GamePT, SetState } from "types/react";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
import { SongChartsSearch } from "types/api-returns";
|
||||
import DebounceSearch from "components/util/DebounceSearch";
|
||||
import Muted from "components/util/Muted";
|
||||
|
||||
@@ -25,16 +25,12 @@ import {
|
||||
SongDocument,
|
||||
} from "tachi-common";
|
||||
import { UGPTPreferenceStatsReturn } from "types/api-returns";
|
||||
import { GamePT } from "types/react";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { GamePT, UGPT } from "types/react";
|
||||
import { Playtype } from "tachi-common";
|
||||
import UGPTStatContainer from "./UGPTStatContainer";
|
||||
import UGPTStatCreator from "./UGPTStatCreator";
|
||||
|
||||
export default function UGPTStatShowcase({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
}: { reqUser: UserDocument } & GamePT) {
|
||||
export default function UGPTStatShowcase({ reqUser, game, playtype }: UGPT) {
|
||||
const { ugs } = useContext(AllLUGPTStatsContext);
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Game, GetGamePTConfig } from "tachi-common";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
import useLUGPTSettings from "./useLUGPTSettings";
|
||||
|
||||
export function useBucket(game: Game, playtype: Playtype) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
SessionCalculatedDataLookup,
|
||||
UGSRatingsLookup,
|
||||
} from "tachi-common";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
import useLUGPTSettings from "./useLUGPTSettings";
|
||||
|
||||
export default function useScoreRatingAlg<I extends IDStrings = IDStrings>(
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { UserDocument } from "tachi-common";
|
||||
import { GamePT } from "types/react";
|
||||
import { UGPT } from "types/react";
|
||||
|
||||
export default function useUGPTBase({
|
||||
reqUser,
|
||||
game,
|
||||
playtype,
|
||||
}: { reqUser: UserDocument } & GamePT) {
|
||||
export default function useUGPTBase({ reqUser, game, playtype }: UGPT) {
|
||||
return useMemo(
|
||||
() => `/dashboard/users/${reqUser.username}/games/${game}/${playtype}`,
|
||||
[reqUser, game, playtype]
|
||||
|
||||
@@ -218,3 +218,14 @@ export interface ImportsReturn {
|
||||
imports: Array<ImportDocument>;
|
||||
users: Array<UserDocument>;
|
||||
}
|
||||
|
||||
export interface ActivityReturn {
|
||||
recentSessions: Array<SessionDocument>;
|
||||
|
||||
songs: Array<SongDocument>;
|
||||
charts: Array<ChartDocument>;
|
||||
recentlyHighlightedScores: Array<ScoreDocument>;
|
||||
achievedClasses: Array<ClassAchievementDocument>;
|
||||
|
||||
users: Array<UserDocument>;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
FormikTouched,
|
||||
} from "formik";
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
import { Game, Playtypes } from "tachi-common";
|
||||
import { Game, Playtype, UserDocument } from "tachi-common";
|
||||
|
||||
export interface JustChildren {
|
||||
children: React.ReactNode;
|
||||
@@ -87,5 +87,11 @@ export type UseFormik<Values> = {
|
||||
|
||||
export interface GamePT {
|
||||
game: Game;
|
||||
playtype: Playtypes[Game];
|
||||
playtype: Playtype;
|
||||
}
|
||||
|
||||
export interface UGPT {
|
||||
reqUser: UserDocument;
|
||||
game: Game;
|
||||
playtype: Playtype;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
import { Game, Playtypes } from "tachi-common";
|
||||
import {
|
||||
ChartDocument,
|
||||
ClassAchievementDocument,
|
||||
ScoreDocument,
|
||||
SessionDocument,
|
||||
SongDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
export type Playtype = Playtypes[Game];
|
||||
export type ClumpedActivityScores = {
|
||||
type: "SCORES";
|
||||
scores: Array<ScoreDocument & { __related: { song: SongDocument; chart: ChartDocument } }>;
|
||||
};
|
||||
|
||||
export type ClumpedActivitySession = {
|
||||
type: "SESSION";
|
||||
} & SessionDocument;
|
||||
|
||||
export type ClumpedActivityClassAchievement = {
|
||||
type: "CLASS_ACHIEVEMENT";
|
||||
} & ClassAchievementDocument;
|
||||
|
||||
export type ClumpedActivity = Array<
|
||||
ClumpedActivitySession | ClumpedActivityScores | ClumpedActivityClassAchievement
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ActivityReturn } from "types/api-returns";
|
||||
import { ClumpedActivity, ClumpedActivityScores } from "types/tachi";
|
||||
import { ONE_HOUR } from "./constants/time";
|
||||
import { CreateSongMap, CreateChartMap } from "./data";
|
||||
import { NumericSOV } from "./sorts";
|
||||
|
||||
/**
|
||||
* Given recent activity data, clump it together so it's easier to work with,
|
||||
* by joining repeat-highlighted scores from the same user.
|
||||
*/
|
||||
export function ClumpActivity(data: ActivityReturn): ClumpedActivity {
|
||||
const songMap = CreateSongMap(data.songs);
|
||||
const chartMap = CreateChartMap(data.charts);
|
||||
|
||||
const clumped: ClumpedActivity = [];
|
||||
|
||||
let clump: ClumpedActivityScores["scores"] = [];
|
||||
let curUserID: number | null = null;
|
||||
let lastTime: number | null = null;
|
||||
|
||||
for (const score of data.recentlyHighlightedScores
|
||||
.slice(0)
|
||||
.sort(NumericSOV((x) => x.timeAchieved ?? -Infinity, true))) {
|
||||
const song = songMap.get(score.songID);
|
||||
const chart = chartMap.get(score.chartID);
|
||||
|
||||
if (!song || !chart) {
|
||||
console.warn(`Failed to resolve song or chart ${score.songID}, ${chart?.chartID}.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(curUserID !== score.userID && curUserID !== null) ||
|
||||
(lastTime !== null && lastTime - (score.timeAchieved ?? 0) > ONE_HOUR * 8)
|
||||
) {
|
||||
clumped.push({ type: "SCORES", scores: clump });
|
||||
|
||||
clump = [];
|
||||
}
|
||||
|
||||
curUserID = score.userID;
|
||||
lastTime = score.timeAchieved;
|
||||
|
||||
clump.push({
|
||||
...score,
|
||||
__related: {
|
||||
song,
|
||||
chart,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (clump.length !== 0) {
|
||||
clumped.push({ type: "SCORES", scores: clump });
|
||||
}
|
||||
|
||||
for (const session of data.recentSessions) {
|
||||
clumped.push({
|
||||
type: "SESSION",
|
||||
...session,
|
||||
});
|
||||
}
|
||||
|
||||
for (const ach of data.achievedClasses) {
|
||||
clumped.push({
|
||||
type: "CLASS_ACHIEVEMENT",
|
||||
...ach,
|
||||
});
|
||||
}
|
||||
|
||||
return clumped.sort(
|
||||
NumericSOV((x) => {
|
||||
switch (x.type) {
|
||||
case "SESSION":
|
||||
return x.timeStarted;
|
||||
case "SCORES":
|
||||
return x.scores[0]?.timeAchieved ?? -Infinity;
|
||||
case "CLASS_ACHIEVEMENT":
|
||||
return x.timeAchieved;
|
||||
}
|
||||
}, true)
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
ScoreCalculatedDataLookup,
|
||||
ScoreDocument,
|
||||
SessionDocument,
|
||||
Playtype,
|
||||
} from "tachi-common";
|
||||
import { Playtype } from "types/tachi";
|
||||
import fjsh from "fast-json-stable-hash";
|
||||
|
||||
export function RFA<T>(arr: T[]): T {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
IDStrings,
|
||||
} from "tachi-common";
|
||||
import { ComparePBsDataset, FolderDataset, PBDataset, ScoreDataset } from "types/tables";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
|
||||
function GetBMSTableVal(chart: ChartDocument<"bms:7K" | "bms:14K">, key: string) {
|
||||
for (const table of chart.data.tableFolders) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Game, GPTTierlists, IDStrings, IIDX_LAMPS, ScoreDocument, SDVX_LAMPS } from "tachi-common";
|
||||
import { Playtype } from "types/tachi";
|
||||
import { Playtype } from "tachi-common";
|
||||
|
||||
type ScaleAchievedFns = {
|
||||
[I in IDStrings]: {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import db from "external/mongo/db";
|
||||
import { GetRelevantSongsAndCharts } from "utils/db";
|
||||
import { DedupeArr } from "utils/misc";
|
||||
import { GetGPT } from "utils/req-tachi-data";
|
||||
import { GetUsersWithIDs } from "utils/user";
|
||||
import type { Request, Response } from "express-serve-static-core";
|
||||
import type { FilterQuery } from "mongodb";
|
||||
import type { ClassAchievementDocument, Game, ScoreDocument, SessionDocument } from "tachi-common";
|
||||
|
||||
type ActivityConstraint = FilterQuery<ClassAchievementDocument & ScoreDocument & SessionDocument>;
|
||||
|
||||
/**
|
||||
* Retrieves recent activity for this group of users for this GPT.
|
||||
*
|
||||
* At the moment, this retrieves the following events:
|
||||
* - Recent Sessions
|
||||
* - Recent Highlighted Scores
|
||||
* - Achieved Classes
|
||||
*
|
||||
* It will support:
|
||||
* - Recently achieved goals
|
||||
* - Recently achieved quests
|
||||
*
|
||||
* To get the set of things we want to fetch, we fetch the first N sessions we see.
|
||||
* This sets our "upper bound" for how far back we want to look -- when the Nth session
|
||||
* started.
|
||||
*
|
||||
* Optionally, startFrom can be passed, which will start this activity search from that
|
||||
* point in time.
|
||||
*/
|
||||
export async function GetRecentActivity(
|
||||
// todo: is it possible to make this game agnostic?
|
||||
game: Game,
|
||||
query: ActivityConstraint,
|
||||
sessions = 30,
|
||||
startFrom: number | null = null
|
||||
) {
|
||||
const baseQuery = query;
|
||||
|
||||
const initialSessionQuery = {
|
||||
...baseQuery,
|
||||
|
||||
// start from anytime if startFrom is omitted, otherwise, cap at the start.
|
||||
timeStarted: { $lt: startFrom === null ? Infinity : startFrom },
|
||||
};
|
||||
|
||||
const recentSessions = await db.sessions.find(initialSessionQuery, {
|
||||
sort: {
|
||||
timeStarted: -1,
|
||||
},
|
||||
limit: sessions,
|
||||
});
|
||||
|
||||
// find the earliest point in the sessions we just fetched.
|
||||
// if we found no sessions, set this to now, which means we'll fetch no highlighted
|
||||
// scores.
|
||||
// (it's not possible to have no sessions *and* have scores with timestamps)
|
||||
const earliestSession = recentSessions.at(-1)?.timeStarted ?? Date.now();
|
||||
|
||||
const timeConstraint =
|
||||
startFrom !== null
|
||||
? { $lt: startFrom, $gte: earliestSession }
|
||||
: {
|
||||
$gte: earliestSession,
|
||||
};
|
||||
|
||||
const achievedClasses = await db["class-achievements"].find(
|
||||
{
|
||||
...baseQuery,
|
||||
timeAchieved: timeConstraint,
|
||||
},
|
||||
{
|
||||
sort: {
|
||||
timeAchieved: -1,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const recentlyHighlightedScores = await db.scores.find(
|
||||
{
|
||||
...baseQuery,
|
||||
highlight: true,
|
||||
timeAchieved: timeConstraint,
|
||||
},
|
||||
{
|
||||
sort: {
|
||||
timeAchieved: -1,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const { songs, charts } = await GetRelevantSongsAndCharts(recentlyHighlightedScores, game);
|
||||
|
||||
const userIDs = DedupeArr([
|
||||
...recentSessions.map((e) => e.userID),
|
||||
...recentlyHighlightedScores.map((e) => e.userID),
|
||||
...achievedClasses.map((e) => e.userID),
|
||||
]);
|
||||
|
||||
const users = await GetUsersWithIDs(userIDs);
|
||||
|
||||
return {
|
||||
recentSessions,
|
||||
recentlyHighlightedScores,
|
||||
songs,
|
||||
charts,
|
||||
achievedClasses,
|
||||
users,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility for creating an express handler for activity-related endpoints. These endpoints
|
||||
* are all *remarkably* similar, but with slightly different initial constraints.
|
||||
*
|
||||
* This creates a function that you should call inside another route.
|
||||
*/
|
||||
export function CreateActivityRouteHandler(query: ActivityConstraint) {
|
||||
return async (req: Request, res: Response) => {
|
||||
const { game } = GetGPT(req);
|
||||
|
||||
const qSessions = req.query.sessions;
|
||||
const qStartTime = req.query.startTime;
|
||||
|
||||
if (qSessions !== undefined && typeof qSessions !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid 'sessions'.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (qStartTime !== undefined && typeof qStartTime !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid 'startTime'.`,
|
||||
});
|
||||
}
|
||||
|
||||
// defaulting to 30 seems sensible.
|
||||
const sessions = qSessions ? Number(qSessions) : 30;
|
||||
|
||||
if (sessions > 100 || sessions < 10 || Number.isNaN(sessions)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid sessions, got ${sessions}, which wasn't between 10 and 100.`,
|
||||
});
|
||||
}
|
||||
|
||||
const startTime = qStartTime ? Number(qStartTime) : null;
|
||||
|
||||
if (Number.isNaN(startTime)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid startTime, got a non number.`,
|
||||
});
|
||||
}
|
||||
|
||||
const recentActivity = await GetRecentActivity(game, query, sessions, startTime);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Retrieved activity.`,
|
||||
body: recentActivity,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { FormatGame } from "tachi-common";
|
||||
import { ArrayDiff } from "utils/misc";
|
||||
import { GetUsersWithIDs, GetUserWithIDGuaranteed } from "utils/user";
|
||||
import type { BulkWriteUpdateOneOperation } from "mongodb";
|
||||
import type { Game, integer, Playtype, PBScoreDocument } from "tachi-common";
|
||||
import type { Game, integer, PBScoreDocument, Playtype } from "tachi-common";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import chartsRouter from "./charts/router";
|
||||
import foldersRouter from "./folders/router";
|
||||
import { ValidatePlaytypeFromParam } from "./middleware";
|
||||
import scoresRouter from "./scores/router";
|
||||
import songIDRouter from "./songs/_songID/router";
|
||||
import tablesRouter from "./tables/router";
|
||||
import targetsRouter from "./targets/router";
|
||||
import { Router } from "express";
|
||||
import db from "external/mongo/db";
|
||||
import { CreateActivityRouteHandler } from "lib/activity/activity";
|
||||
import { ONE_HOUR } from "lib/constants/time";
|
||||
import { SearchUsersRegExp } from "lib/search/search";
|
||||
import NodeCache from "node-cache";
|
||||
import p from "prudence";
|
||||
import prValidate from "server/middleware/prudence-validate";
|
||||
import { FormatGame, GetGamePTConfig } from "tachi-common";
|
||||
import { GetRelevantSongsAndCharts } from "utils/db";
|
||||
import { IsString } from "utils/misc";
|
||||
import { GetClassDistribution } from "utils/queries/stats";
|
||||
import { GetGPT } from "utils/req-tachi-data";
|
||||
import {
|
||||
CheckStrProfileAlg,
|
||||
@@ -24,7 +22,7 @@ import {
|
||||
} from "utils/string-checks";
|
||||
import { GetUsersWithIDs } from "utils/user";
|
||||
import type { FindOptions } from "monk";
|
||||
import type { Game, integer, Playtype, UserGameStats, gameClasses, IDStrings } from "tachi-common";
|
||||
import type { Game, integer, Playtype, UserGameStats } from "tachi-common";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
@@ -214,88 +212,6 @@ router.get("/pb-leaderboard", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Return the distribution of players for the provided class.
|
||||
*
|
||||
* @param class - This should be one of the games supported classes.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/class-distribution
|
||||
*/
|
||||
router.get(
|
||||
"/class-distribution",
|
||||
prValidate({
|
||||
class: "string",
|
||||
}),
|
||||
async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const stat = req.query.class as string;
|
||||
|
||||
const supportedClasses = Object.keys(gptConfig.classHumanisedFormat);
|
||||
|
||||
if (!supportedClasses.includes(stat)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid stat ${stat}. Expected any of ${supportedClasses.join(
|
||||
", "
|
||||
)}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const distribution = await GetClassDistribution(
|
||||
game,
|
||||
playtype,
|
||||
stat as gameClasses.GameClassSets[IDStrings]
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Successfully retrieved distribution.`,
|
||||
body: distribution,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns recent class improvements for this GPT.
|
||||
*
|
||||
* @param limit - How many to return. Defaults to 10, caps at 50.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/recent-classes
|
||||
*/
|
||||
router.get(
|
||||
"/recent-classes",
|
||||
prValidate({
|
||||
limit: p.optional((self) => p.isBoundedInteger(1, 50)(Number(self))),
|
||||
}),
|
||||
async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
// validated to never be NaN by prudence.
|
||||
const limit = req.query.limit !== undefined ? Number(req.query.limit) : 10;
|
||||
|
||||
const recentClasses = await db["class-achievements"].find(
|
||||
{
|
||||
game,
|
||||
playtype,
|
||||
},
|
||||
{
|
||||
limit,
|
||||
sort: { timeAchieved: -1 },
|
||||
}
|
||||
);
|
||||
|
||||
const users = await GetUsersWithIDs(recentClasses.map((e) => e.userID));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${recentClasses.length} recent classes.`,
|
||||
body: { classes: recentClasses, users },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Search users that have played this game.
|
||||
*
|
||||
@@ -335,11 +251,27 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Retrieve activity for this GPT.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/activity
|
||||
*/
|
||||
router.get("/activity", (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const route = CreateActivityRouteHandler({
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
|
||||
// this handles responding
|
||||
void route(req, res);
|
||||
});
|
||||
|
||||
router.use("/charts", chartsRouter);
|
||||
router.use("/songs/:songID", songIDRouter);
|
||||
router.use("/folders", foldersRouter);
|
||||
router.use("/tables", tablesRouter);
|
||||
router.use("/scores", scoresRouter);
|
||||
router.use("/targets", targetsRouter);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Router } from "express";
|
||||
import db from "external/mongo/db";
|
||||
import p from "prudence";
|
||||
import prValidate from "server/middleware/prudence-validate";
|
||||
import { GetRelevantSongsAndCharts } from "utils/db";
|
||||
import { GetGPT } from "utils/req-tachi-data";
|
||||
import { GetUsersWithIDs } from "utils/user";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Return the most recent highlighted scores for this game.
|
||||
*
|
||||
* @param limit - Return up to this amount. Caps at 100, defaults to 100.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/scores/highlighted
|
||||
*/
|
||||
router.get(
|
||||
"/highlighted",
|
||||
prValidate({
|
||||
limit: p.optional((self) => p.isBoundedInteger(1, 100)(Number(self))),
|
||||
}),
|
||||
async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
// validated to not be NaN by prudence.
|
||||
const limit = req.query.limit !== undefined ? Number(req.query.limit) : 100;
|
||||
|
||||
const scores = await db.scores.find(
|
||||
{
|
||||
game,
|
||||
playtype,
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
limit,
|
||||
sort: { timeAchieved: -1 },
|
||||
}
|
||||
);
|
||||
|
||||
const users = await GetUsersWithIDs(scores.map((e) => e.userID));
|
||||
|
||||
const { songs, charts } = await GetRelevantSongsAndCharts(scores, game);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${scores.length} scores.`,
|
||||
body: {
|
||||
scores,
|
||||
users,
|
||||
songs,
|
||||
charts,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RequireAuthedAsUser } from "../../../../middleware";
|
||||
import { Router } from "express";
|
||||
import db from "external/mongo/db";
|
||||
import { CreateActivityRouteHandler } from "lib/activity/activity";
|
||||
import { SetRivalsFailReasons } from "lib/constants/err-codes";
|
||||
import { GetChallengerUsers, GetRivalIDs, GetRivalUsers, SetRivals } from "lib/rivals/rivals";
|
||||
import p from "prudence";
|
||||
@@ -167,4 +168,24 @@ router.get("/pb-leaderboard", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieve activity for this user's set of rivals.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/games/:game/:playtype/rivals/activity
|
||||
*/
|
||||
router.get("/activity", async (req, res) => {
|
||||
const { game, playtype, user } = GetUGPT(req);
|
||||
|
||||
const rivalIDs = await GetRivalIDs(user.id, game, playtype);
|
||||
|
||||
const route = CreateActivityRouteHandler({
|
||||
userID: { $in: rivalIDs },
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
|
||||
// this handles responding
|
||||
void route(req, res);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -10,6 +10,7 @@ import tablesRouter from "./tables/router";
|
||||
import targetsRouter from "./targets/router";
|
||||
import { Router } from "express";
|
||||
import db from "external/mongo/db";
|
||||
import { CreateActivityRouteHandler } from "lib/activity/activity";
|
||||
import { ONE_MONTH, ONE_YEAR } from "lib/constants/time";
|
||||
import p from "prudence";
|
||||
import prValidate from "server/middleware/prudence-validate";
|
||||
@@ -311,6 +312,24 @@ router.get("/leaderboard-adjacent", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieve activity for this user.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/games/:game/:playtype/activity
|
||||
*/
|
||||
router.get("/activity", (req, res) => {
|
||||
const { game, playtype, user } = GetUGPT(req);
|
||||
|
||||
const route = CreateActivityRouteHandler({
|
||||
userID: user.id,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
|
||||
// this handles responding
|
||||
void route(req, res);
|
||||
});
|
||||
|
||||
router.use("/pbs", pbsRouter);
|
||||
router.use("/scores", scoresRouter);
|
||||
router.use("/sessions", sessionsRouter);
|
||||
|
||||
@@ -379,6 +379,8 @@ router.post(
|
||||
|
||||
/**
|
||||
* Get the recent import types this user has used.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/recent-imports
|
||||
*/
|
||||
router.get("/recent-imports", async (req, res) => {
|
||||
const user = NotNullish(req[SYMBOL_TACHI_DATA]?.requestedUser);
|
||||
|
||||
Reference in New Issue
Block a user