feat: full ugpt activity stuff

This commit is contained in:
zkldi
2022-11-11 04:51:39 +00:00
parent e5cab18365
commit ac7486592b
14 changed files with 386 additions and 686 deletions
@@ -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`} />;
}
@@ -4,7 +4,7 @@ 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 UGPTActivity from "components/user/UGPTActivity";
import Activity from "components/activity/Activity";
import UGPTStatShowcase from "components/user/UGPTStatShowcase";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
@@ -18,7 +18,7 @@ import { useProfileRatingAlg } from "components/util/useScoreRatingAlg";
import { DateTime } from "luxon";
import React, { useMemo, useState } from "react";
import { FormatGame, GetGameConfig, GetGamePTConfig, UserGameStats } from "tachi-common";
import { ActivityReturn, UGPTHistory } from "types/api-returns";
import { UGPTHistory } from "types/api-returns";
import { GamePT, SetState, UGPT } from "types/react";
export default function OverviewPage({ reqUser, game, playtype }: UGPT) {
@@ -39,27 +39,12 @@ export default function OverviewPage({ reqUser, game, playtype }: UGPT) {
}
function RecentActivity({ reqUser, game, playtype }: UGPT) {
const { data, error } = useApiQuery<ActivityReturn>(
`/users/${reqUser.id}/games/${game}/${playtype}/activity`
);
if (error) {
return <ApiError error={error} />;
}
if (!data) {
return <Loading />;
}
const joined = ClumpActivity(data);
if (joined.length === 0) {
return null;
}
return (
<div className="mt-4">
<UGPTActivity data={joined} users={data.users} />
<Activity
url={`/users/${reqUser.id}/games/${game}/${playtype}/activity`}
handleNoActivity={null}
/>
</div>
);
}
@@ -1,26 +1,7 @@
import { ClumpActivity } from "util/activity";
import ApiError from "components/util/ApiError";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import React, { useState } from "react";
import { ActivityReturn } from "types/api-returns";
import Activity from "components/activity/Activity";
import React from "react";
import { UGPT } from "types/react";
import UGPTActivity from "components/user/UGPTActivity";
export default function RivalsActivityPage({ reqUser, game, playtype }: UGPT) {
const { data, error } = useApiQuery<ActivityReturn>(
`/users/${reqUser.id}/games/${game}/${playtype}/rivals/activity`
);
if (error) {
return <ApiError error={error} />;
}
if (!data) {
return <Loading />;
}
const joined = ClumpActivity(data);
return <UGPTActivity data={joined} users={data.users} />;
return <Activity url={`/users/${reqUser.id}/games/${game}/${playtype}/rivals/activity`} />;
}
@@ -2,6 +2,8 @@ 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";
@@ -11,34 +13,90 @@ 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, { useState } from "react";
import { Col, Row } from "react-bootstrap";
import { FormatChart, SessionDocument, UserDocument } from "tachi-common";
import { SessionReturns } from "types/api-returns";
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, ClumpedActivityScores } from "types/tachi";
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 UGPTActivity({
data,
users,
export default function Activity({
url,
handleNoActivity = (
<Col xs={12} className="text-center">
We found no activity!
</Col>
),
}: {
data: ClumpedActivity;
users: Array<UserDocument>;
url: string;
handleNoActivity?: React.ReactNode;
}) {
const userMap = CreateUserMap(users);
const [clumped, setClumped] = useState<ClumpedActivity>([]);
const [users, setUsers] = useState<Array<UserDocument>>([]);
if (data.length === 0) {
return (
<Col xs={12} className="text-center">
We found no activity!
</Col>
);
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">
<div className="timeline timeline-2">
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);
@@ -47,11 +105,14 @@ export default function UGPTActivity({
return <div>This user doesn't exist? Whoops.</div>;
}
return e.type === "SCORES" ? (
<ScoresActivity data={e} user={user} />
) : (
<SessionActivity data={e} user={user} />
);
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">
@@ -64,7 +125,33 @@ export default function UGPTActivity({
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>
@@ -73,7 +160,7 @@ export default function UGPTActivity({
);
}
function ScoresActivity({ data, user: user }: { data: ClumpedActivityScores; user: UserDocument }) {
function ScoresActivity({ data, user }: { data: ClumpedActivityScores; user: UserDocument }) {
const { game, playtype } = data.scores[0];
const [show, setShow] = useState(false);
@@ -171,13 +258,7 @@ function ScoresActivity({ data, user: user }: { data: ClumpedActivityScores; use
);
}
function SessionActivity({
data,
user: rival,
}: {
data: { type: "SESSION" } & SessionDocument;
user: UserDocument;
}) {
function SessionActivity({ data, user }: { data: ClumpedActivitySession; user: UserDocument }) {
const [show, setShow] = useState(false);
const isProbablyActive = Date.now() - data.timeEnded < ONE_HOUR;
@@ -211,7 +292,7 @@ function SessionActivity({
fontSize: isProbablyActive ? "1.2rem" : undefined,
}}
>
{rival.username} {isProbablyActive ? "is having" : "had"} a session '
{user.username} {isProbablyActive ? "is having" : "had"} a session '
{data.name}' with {data.scoreInfo.length}{" "}
{data.scoreInfo.length === 1 ? "score" : "scores"}.
</span>
@@ -263,3 +344,64 @@ function SessionShower({ sessionID }: { sessionID: string }) {
</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>
);
}
+2 -2
View File
@@ -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
+1
View File
@@ -225,6 +225,7 @@ export interface ActivityReturn {
songs: Array<SongDocument>;
charts: Array<ChartDocument>;
recentlyHighlightedScores: Array<ScoreDocument>;
achievedClasses: Array<ClassAchievementDocument>;
users: Array<UserDocument>;
}
+16 -5
View File
@@ -1,13 +1,24 @@
import { ChartDocument, ScoreDocument, SessionDocument, SongDocument } from "tachi-common";
import {
ChartDocument,
ClassAchievementDocument,
ScoreDocument,
SessionDocument,
SongDocument,
} from "tachi-common";
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<
| ({
type: "SESSION";
} & SessionDocument)
| ClumpedActivityScores
ClumpedActivitySession | ClumpedActivityScores | ClumpedActivityClassAchievement
>;
+17 -4
View File
@@ -61,10 +61,23 @@ export function ClumpActivity(data: ActivityReturn): ClumpedActivity {
});
}
for (const ach of data.achievedClasses) {
clumped.push({
type: "CLASS_ACHIEVEMENT",
...ach,
});
}
return clumped.sort(
NumericSOV(
(x) => (x.type === "SESSION" ? x.timeEnded : x.scores[0]!.timeAchieved ?? -Infinity),
true
)
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)
);
}
+117 -20
View File
@@ -1,7 +1,13 @@
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 { Game, integer, Playtype } from "tachi-common";
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.
@@ -9,30 +15,41 @@ import type { Game, integer, Playtype } from "tachi-common";
* 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(
userIDs: Array<integer>,
// todo: is it possible to make this game agnostic?
game: Game,
playtype: Playtype,
sessions = 30
query: ActivityConstraint,
sessions = 30,
startFrom: number | null = null
) {
const recentSessions = await db.sessions.find(
{
userID: { $in: userIDs },
game,
playtype,
let baseQuery = query;
if (startFrom !== null) {
baseQuery = {
...baseQuery,
timeStarted: { $lt: startFrom },
};
}
const recentSessions = await db.sessions.find(baseQuery, {
sort: {
timeStarted: -1,
},
{
sort: {
timeStarted: -1,
},
limit: sessions,
}
);
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
@@ -40,13 +57,30 @@ export async function GetRecentActivity(
// (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(
{
userID: { $in: userIDs },
game,
playtype,
...baseQuery,
highlight: true,
timeAchieved: { $gte: earliestSession },
timeAchieved: timeConstraint,
},
{
sort: {
@@ -57,6 +91,12 @@ export async function GetRecentActivity(
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 {
@@ -64,6 +104,63 @@ export async function GetRecentActivity(
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,
});
};
}
+1 -13
View File
@@ -1,5 +1,4 @@
import db from "external/mongo/db";
import { GetRecentActivity } from "lib/activity/activity";
import { SetRivalsFailReasons } from "lib/constants/err-codes";
import CreateLogCtx from "lib/logger/logger";
import { SendSetRivalNotification } from "lib/notifications/notification-wrappers";
@@ -7,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, PBScoreDocument, Playtype, UserDocument } from "tachi-common";
import type { Game, integer, PBScoreDocument, Playtype } from "tachi-common";
const logger = CreateLogCtx(__filename);
@@ -271,14 +270,3 @@ export async function UpdatePlayersRivalRankings(userID: integer, game: Game, pl
await db["personal-bests"].bulkWrite(bwrite, { ordered: false });
}
export async function GetRivalRecentActivity(
user: UserDocument,
game: Game,
playtype: Playtype,
timespan: number
) {
const rivalIDs = await GetRivalIDs(user.id, game, playtype);
return GetRecentActivity(rivalIDs, game, playtype, timespan);
}
@@ -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,15 +1,9 @@
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 { ONE_MONTH, ONE_YEAR } from "lib/constants/time";
import {
GetChallengerUsers,
GetRivalIDs,
GetRivalRecentActivity,
GetRivalUsers,
SetRivals,
} from "lib/rivals/rivals";
import { GetChallengerUsers, GetRivalIDs, GetRivalUsers, SetRivals } from "lib/rivals/rivals";
import p from "prudence";
import { RequirePermissions } from "server/middleware/auth";
import prValidate from "server/middleware/prudence-validate";
@@ -177,31 +171,21 @@ router.get("/pb-leaderboard", async (req, res) => {
/**
* Retrieve activity for this user's set of rivals.
*
* @param sessions - How many sessions' worth of activity do we want?
* This is a more reliable way of fetching *atleast some* activity than say,
* time.
*
* @name GET /api/v1/users/:userID/games/:game/:playtype/rivals/activity
*/
router.get(
"/activity",
prValidate({ sessions: p.optional(p.isIn("month", "3month", "year")) }),
async (req, res) => {
const { user, game, playtype } = GetUGPT(req);
router.get("/activity", async (req, res) => {
const { game, playtype, user } = GetUGPT(req);
const qSessions = req.query.sessions as string | undefined;
const rivalIDs = await GetRivalIDs(user.id, game, playtype);
// defaulting to 30 seems sensible.
const sessions = qSessions ? Number(qSessions) : 30;
const route = CreateActivityRouteHandler({
userID: { $in: rivalIDs },
game,
playtype,
});
const recentActivity = await GetRivalRecentActivity(user, game, playtype, sessions);
return res.status(200).json({
success: true,
description: `Retrieved recent rival activity.`,
body: recentActivity,
});
}
);
// this handles responding
void route(req, res);
});
export default router;
@@ -10,7 +10,7 @@ import tablesRouter from "./tables/router";
import targetsRouter from "./targets/router";
import { Router } from "express";
import db from "external/mongo/db";
import { GetRecentActivity } from "lib/activity/activity";
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";
@@ -315,32 +315,20 @@ router.get("/leaderboard-adjacent", async (req, res) => {
/**
* Retrieve activity for this user.
*
* @param sessions - How many sessions' worth of activity do we want?
* This is a more reliable way of fetching *atleast some* activity than say,
* time.
*
* @name GET /api/v1/users/:userID/games/:game/:playtype/rivals/activity
* @name GET /api/v1/users/:userID/games/:game/:playtype/activity
*/
router.get(
"/activity",
prValidate({ sessions: p.optional((s) => p.isBoundedInteger(10, 100)(Number(s))) }),
async (req, res) => {
const { user, game, playtype } = GetUGPT(req);
router.get("/activity", (req, res) => {
const { game, playtype, user } = GetUGPT(req);
const qSessions = req.query.sessions as string | undefined;
const route = CreateActivityRouteHandler({
userID: user.id,
game,
playtype,
});
// defaulting to 30 seems sensible.
const sessions = qSessions ? Number(qSessions) : 30;
const recentActivity = await GetRecentActivity([user.id], game, playtype, sessions);
return res.status(200).json({
success: true,
description: `Retrieved recent rival activity.`,
body: recentActivity,
});
}
);
// this handles responding
void route(req, res);
});
router.use("/pbs", pbsRouter);
router.use("/scores", scoresRouter);