+ Tip: You can click on an event to learn more about it.
+
{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
This user doesn't exist? Whoops.
;
}
- return e.type === "SCORES" ? (
-
- ) : (
-
- );
+ switch (e.type) {
+ case "SCORES":
+ return
;
+ case "SESSION":
+ return
;
+ case "CLASS_ACHIEVEMENT":
+ return
;
+ }
})}
@@ -64,7 +125,33 @@ export default function UGPTActivity({
marginRight: "2rem",
}}
>
- ...
+
@@ -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"}.
@@ -263,3 +344,64 @@ function SessionShower({ sessionID }: { sessionID: string }) {
);
}
+
+function ClassAchievementActivity({
+ data,
+ user,
+}: {
+ data: ClumpedActivityClassAchievement;
+ user: UserDocument;
+}) {
+ return (
+
+
+
+
+
+
+ {user.username}
+ {" "}
+ achieved{" "}
+
+ {data.classOldValue !== null && (
+ <>
+ {" "}
+ (Raised from{" "}
+
+ )
+ >
+ )}
+
+
+
+ {MillisToSince(data.timeAchieved)}
+
+
+ {FormatTime(data.timeAchieved)}
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/game/GPTHeader.tsx b/client/src/components/game/GPTHeader.tsx
index eaf1fdd85..b90aec9da 100644
--- a/client/src/components/game/GPTHeader.tsx
+++ b/client/src/components/game/GPTHeader.tsx
@@ -7,8 +7,8 @@ export function GPTBottomNav({ baseUrl }: { baseUrl: string }) {
const { settings } = useContext(UserSettingsContext);
const navItems = [
-
- Overview
+
+ Activity
,
Songs
diff --git a/client/src/types/api-returns.ts b/client/src/types/api-returns.ts
index 2979c1edc..54aea8d95 100644
--- a/client/src/types/api-returns.ts
+++ b/client/src/types/api-returns.ts
@@ -225,6 +225,7 @@ export interface ActivityReturn {
songs: Array;
charts: Array;
recentlyHighlightedScores: Array;
+ achievedClasses: Array;
users: Array;
}
diff --git a/client/src/types/tachi.ts b/client/src/types/tachi.ts
index 3a54324c4..06f70f102 100644
--- a/client/src/types/tachi.ts
+++ b/client/src/types/tachi.ts
@@ -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;
};
+export type ClumpedActivitySession = {
+ type: "SESSION";
+} & SessionDocument;
+
+export type ClumpedActivityClassAchievement = {
+ type: "CLASS_ACHIEVEMENT";
+} & ClassAchievementDocument;
+
export type ClumpedActivity = Array<
- | ({
- type: "SESSION";
- } & SessionDocument)
- | ClumpedActivityScores
+ ClumpedActivitySession | ClumpedActivityScores | ClumpedActivityClassAchievement
>;
diff --git a/client/src/util/activity.ts b/client/src/util/activity.ts
index be41546f0..7431be54b 100644
--- a/client/src/util/activity.ts
+++ b/client/src/util/activity.ts
@@ -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)
);
}
diff --git a/server/src/lib/activity/activity.ts b/server/src/lib/activity/activity.ts
index da32a967b..54112d2f7 100644
--- a/server/src/lib/activity/activity.ts
+++ b/server/src/lib/activity/activity.ts
@@ -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;
/**
* 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,
+ // 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,
+ });
+ };
+}
diff --git a/server/src/lib/rivals/rivals.ts b/server/src/lib/rivals/rivals.ts
index a2a53be9f..5a583732e 100644
--- a/server/src/lib/rivals/rivals.ts
+++ b/server/src/lib/rivals/rivals.ts
@@ -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);
-}
diff --git a/server/src/server/router/api/v1/games/_game/_playtype/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/router.ts
index b9ab72651..bfdb3a7a7 100644
--- a/server/src/server/router/api/v1/games/_game/_playtype/router.ts
+++ b/server/src/server/router/api/v1/games/_game/_playtype/router.ts
@@ -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;
diff --git a/server/src/server/router/api/v1/games/_game/_playtype/scores/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/scores/router.ts
deleted file mode 100644
index 40c8aa3c1..000000000
--- a/server/src/server/router/api/v1/games/_game/_playtype/scores/router.ts
+++ /dev/null
@@ -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;
diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts
index b90fcdf38..2b23fd320 100644
--- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts
+++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts
@@ -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;
diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts
index 5a35730cc..3d5faa5a4 100644
--- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts
+++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts
@@ -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);