From e565208e6e1c0fdf82cace84160db33f0c36febf Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Fri, 11 Nov 2022 18:42:34 +0000 Subject: [PATCH] feat: activity all the things --- .../src/app/pages/dashboard/DashboardPage.tsx | 39 +++-- .../app/pages/dashboard/users/UserPage.tsx | 10 +- client/src/app/routes/DashboardRoutes.tsx | 6 +- client/src/app/routes/GameRoutes.tsx | 2 - client/src/components/activity/Activity.tsx | 148 ++++++++++++++---- .../dashboard/DashboardActivity.tsx | 12 ++ .../components/dashboard/DashboardHeader.tsx | 20 +++ .../sessions/SessionRaiseBreakdown.tsx | 110 +------------ client/src/components/user/UGPTHeader.tsx | 5 +- client/src/types/api-returns.ts | 2 + client/src/util/activity.ts | 49 ++++-- common/src/utils/util.ts | 21 ++- server/src/lib/activity/activity.ts | 4 +- .../router/api/v1/users/_userID/router.ts | 71 ++++++++- 14 files changed, 313 insertions(+), 186 deletions(-) create mode 100644 client/src/components/dashboard/DashboardActivity.tsx create mode 100644 client/src/components/dashboard/DashboardHeader.tsx diff --git a/client/src/app/pages/dashboard/DashboardPage.tsx b/client/src/app/pages/dashboard/DashboardPage.tsx index 81b1f158b..91c645992 100644 --- a/client/src/app/pages/dashboard/DashboardPage.tsx +++ b/client/src/app/pages/dashboard/DashboardPage.tsx @@ -17,11 +17,13 @@ import { UserContext } from "context/UserContext"; import { AllLUGPTStatsContext } from "context/AllLUGPTStatsContext"; import { UserSettingsContext } from "context/UserSettingsContext"; import { ColourConfig, TachiConfig } from "lib/config"; -import React, { useContext } from "react"; +import React, { useContext, useMemo } from "react"; import { Alert } from "react-bootstrap"; -import { Link } from "react-router-dom"; +import { Link, Route, Switch } from "react-router-dom"; import { COLOUR_SET, GetGameConfig, UserDocument } from "tachi-common"; import { UGSWithRankingData, UserRecentSummary } from "types/api-returns"; +import { DashboardHeader } from "components/dashboard/DashboardHeader"; +import DashboardActivity from "components/dashboard/DashboardActivity"; import { FolderInfoComponent } from "./users/games/_game/_playtype/folders/FolderSelectPage"; import { GameStatContainer } from "./users/UserGamesPage"; @@ -37,29 +39,34 @@ export function DashboardPage() { return ; } - return ( - <> - {ugs?.length ? ( - - ) : ( - - )} - - ); + if (ugs?.length === 0) { + return ; + } + + return ; } function DashboardLoggedIn({ user }: { user: UserDocument }) { + const splash = useMemo(() => RFA(heySplashes), [user]); + return (
- {RFA(heySplashes)}, {user.username}. + {splash}, {user.username}. - +
+ +
-

Here's all your profiles.

- - + + + + + + + +
); } diff --git a/client/src/app/pages/dashboard/users/UserPage.tsx b/client/src/app/pages/dashboard/users/UserPage.tsx index e52427936..7bfbadc5c 100644 --- a/client/src/app/pages/dashboard/users/UserPage.tsx +++ b/client/src/app/pages/dashboard/users/UserPage.tsx @@ -9,6 +9,7 @@ import React, { useContext, useEffect, useState } from "react"; import { Button } from "react-bootstrap"; import ReactMarkdown from "react-markdown"; import { UserDocument } from "tachi-common"; +import Activity from "components/activity/Activity"; interface Props { reqUser: UserDocument; @@ -17,7 +18,14 @@ interface Props { export default function UserPage({ reqUser }: Props) { useSetSubheader(["Users", reqUser.username], [reqUser], `${reqUser.username}'s Profile`); - return ; + return ( + <> + + + + + + ); } function InferLinks(aboutMe: string) { diff --git a/client/src/app/routes/DashboardRoutes.tsx b/client/src/app/routes/DashboardRoutes.tsx index d095bc3d2..396e9596d 100644 --- a/client/src/app/routes/DashboardRoutes.tsx +++ b/client/src/app/routes/DashboardRoutes.tsx @@ -67,11 +67,13 @@ export default function DashboardRoutes() { - + {/* this is the easiest (read: least sucky) way of handling */} + {/* these routes */} + - + diff --git a/client/src/app/routes/GameRoutes.tsx b/client/src/app/routes/GameRoutes.tsx index 209a77b3c..e4cbcb431 100644 --- a/client/src/app/routes/GameRoutes.tsx +++ b/client/src/app/routes/GameRoutes.tsx @@ -132,8 +132,6 @@ function SongChartRoutes({ game, playtype }: GamePT) { const { settings } = useContext(UserSettingsContext); - const gptConfig = GetGamePTConfig(game, playtype); - const [activeChart, setActiveChart] = useState(null); useEffect(() => { diff --git a/client/src/components/activity/Activity.tsx b/client/src/components/activity/Activity.tsx index 9c244ce25..a4516f03e 100644 --- a/client/src/components/activity/Activity.tsx +++ b/client/src/components/activity/Activity.tsx @@ -1,9 +1,10 @@ +import { ClumpActivity, GetUsers } from "util/activity"; +import { APIFetchV1 } from "util/api"; +import { ONE_HOUR } from "util/constants/time"; 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 ClassBadge from "components/game/ClassBadge"; import SessionRaiseBreakdown from "components/sessions/SessionRaiseBreakdown"; import ScoreTable from "components/tables/scores/ScoreTable"; import ApiError from "components/util/ApiError"; @@ -13,10 +14,12 @@ 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 React, { useContext, 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 { Link } from "react-router-dom"; +import { FormatChart, FormatGame, UserDocument } from "tachi-common"; +import { ActivityReturn, RecordActivityReturn, SessionReturns } from "types/api-returns"; +import { UGPT } from "types/react"; import { ScoreDataset } from "types/tables"; import { ClumpedActivity, @@ -24,8 +27,8 @@ import { ClumpedActivityScores, ClumpedActivitySession, } from "types/tachi"; -import ClassBadge from "components/game/ClassBadge"; -import { Link } from "react-router-dom"; +import { UserContext } from "context/UserContext"; +import ReferToUser from "components/util/ReferToUser"; // Records activity for a group of users on a GPT. Also used for single users. export default function Activity({ @@ -41,8 +44,9 @@ export default function Activity({ }) { const [clumped, setClumped] = useState([]); const [users, setUsers] = useState>([]); + const [shouldShowGame, setShouldShowGame] = useState(false); - const { data, error } = useApiQuery(url); + const { data, error } = useApiQuery(url); useEffect(() => { if (!data) { @@ -50,7 +54,11 @@ export default function Activity({ setUsers([]); } else { setClumped(ClumpActivity(data)); - setUsers(data.users); + setUsers(GetUsers(data)); + + // show game if this is { "iidx:SP": [], "iidx:DP": [] }... + // to disambiguate + setShouldShowGame(!("users" in data)); } }, [data]); @@ -68,15 +76,18 @@ export default function Activity({ return ( { - APIFetchV1(`${url}?startTime=${start}`).then((r) => { - if (r.success) { - setUsers([...users, ...r.body.users]); - setClumped([...clumped, ...ClumpActivity(r.body)]); + APIFetchV1(`${url}?startTime=${start}`).then( + (r) => { + if (r.success) { + setClumped([...clumped, ...ClumpActivity(r.body)]); + setUsers([...users, ...GetUsers(r.body)]); + } } - }); + ); }} /> ); @@ -86,10 +97,12 @@ function ActivityInner({ data, users, fetchMoreFrom, + shouldShowGame, }: { data: ClumpedActivity; users: Array; fetchMoreFrom: (start: number) => void; + shouldShowGame: boolean; }) { const userMap = CreateUserMap(users); @@ -107,11 +120,29 @@ function ActivityInner({ switch (e.type) { case "SCORES": - return ; + return ( + + ); case "SESSION": - return ; + return ( + + ); case "CLASS_ACHIEVEMENT": - return ; + return ( + + ); } })}
@@ -160,9 +191,19 @@ function ActivityInner({ ); } -function ScoresActivity({ data, user }: { data: ClumpedActivityScores; user: UserDocument }) { +function ScoresActivity({ + data, + user, + shouldShowGame, +}: { + data: ClumpedActivityScores; + user: UserDocument; + shouldShowGame: boolean; +}) { const { game, playtype } = data.scores[0]; + const prettyGame = shouldShowGame ? `${FormatGame(game, playtype)} ` : ""; + const [show, setShow] = useState(false); let subMessage; @@ -171,7 +212,7 @@ function ScoresActivity({ data, user }: { data: ClumpedActivityScores; user: Use if (data.scores.length === 1) { const score0 = data.scores[0]; - subMessage = `a score on ${FormatChart( + subMessage = `a ${prettyGame}score on ${FormatChart( score0.game, score0.__related.song, score0.__related.chart @@ -181,11 +222,11 @@ function ScoresActivity({ data, user }: { data: ClumpedActivityScores; user: Use mutedText = `"${score0.comment}"`; } } else { - subMessage = `${data.scores.length} scores`; + subMessage = `${data.scores.length} ${prettyGame}scores`; mutedText = TruncateString( data.scores - .map((e) => FormatChart(e.game, e.__related.song, e.__related.chart)) + .map((e) => FormatChart(e.game, e.__related.song, e.__related.chart, true)) .join(", "), 100 ); @@ -223,7 +264,8 @@ function ScoresActivity({ data, user }: { data: ClumpedActivityScores; user: Use }} /> - {user.username} highlighted {subMessage}! + highlighted{" "} + {subMessage}! {mutedText && ( <> @@ -258,8 +300,19 @@ function ScoresActivity({ data, user }: { data: ClumpedActivityScores; user: Use ); } -function SessionActivity({ data, user }: { data: ClumpedActivitySession; user: UserDocument }) { +function SessionActivity({ + data, + user, + shouldShowGame, +}: { + data: ClumpedActivitySession; + user: UserDocument; + shouldShowGame: boolean; +}) { const [show, setShow] = useState(false); + const { user: loggedInUser } = useContext(UserContext); + + const prettyGame = shouldShowGame ? `${FormatGame(data.game, data.playtype)} ` : ""; const isProbablyActive = Date.now() - data.timeEnded < ONE_HOUR; @@ -292,9 +345,21 @@ function SessionActivity({ data, user }: { data: ClumpedActivitySession; user: U 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"}. + {/* worst string formatting ever */} + {" "} + {isProbablyActive + ? user.id === loggedInUser?.id + ? "are having" + : "is having" + : "had"}{" "} + a {prettyGame} + session '{data.name}' with {data.scoreInfo.length}{" "} + {data.scoreInfo.length === 1 ? "score" : "scores"} + {data.highlight ? "!" : "."}
{data.desc && data.desc !== "This session has no description." && ( @@ -328,7 +393,7 @@ function SessionShower({ sessionID }: { sessionID: string }) { } return ( - + @@ -348,9 +413,11 @@ function SessionShower({ sessionID }: { sessionID: string }) { function ClassAchievementActivity({ data, user, + shouldShowGame, }: { data: ClumpedActivityClassAchievement; user: UserDocument; + shouldShowGame: boolean; }) { return (
@@ -365,12 +432,7 @@ function ClassAchievementActivity({ >
- - {user.username} - {" "} + {" "} achieved{" "} + {shouldShowGame && ` in ${FormatGame(data.game, data.playtype)}`}! {data.classOldValue !== null && ( <> {" "} @@ -405,3 +468,20 @@ function ClassAchievementActivity({
); } + +function UGPTLink({ reqUser, game, playtype }: UGPT) { + // currently + const { user } = useContext(UserContext); + + return ( + + {user?.id === reqUser.id ? "You" : reqUser.username} + + ); +} diff --git a/client/src/components/dashboard/DashboardActivity.tsx b/client/src/components/dashboard/DashboardActivity.tsx new file mode 100644 index 000000000..41d309f09 --- /dev/null +++ b/client/src/components/dashboard/DashboardActivity.tsx @@ -0,0 +1,12 @@ +import Activity from "components/activity/Activity"; +import React from "react"; +import { UserDocument } from "tachi-common"; + +export default function DashboardActivity({ user }: { user: UserDocument }) { + return ( + <> +
Here's what you and your rivals have been up to!
+ + + ); +} diff --git a/client/src/components/dashboard/DashboardHeader.tsx b/client/src/components/dashboard/DashboardHeader.tsx new file mode 100644 index 000000000..00822365e --- /dev/null +++ b/client/src/components/dashboard/DashboardHeader.tsx @@ -0,0 +1,20 @@ +import Navbar from "components/nav/Navbar"; +import NavItem from "components/nav/NavItem"; +import React from "react"; + +export function DashboardHeader() { + const navItems = [ + + Activity + , + + Your Profiles + , + ]; + + return ( +
+ {navItems} +
+ ); +} diff --git a/client/src/components/sessions/SessionRaiseBreakdown.tsx b/client/src/components/sessions/SessionRaiseBreakdown.tsx index 45750fd08..ffeedd131 100644 --- a/client/src/components/sessions/SessionRaiseBreakdown.tsx +++ b/client/src/components/sessions/SessionRaiseBreakdown.tsx @@ -47,48 +47,6 @@ export default function SessionRaiseBreakdown({ const { user } = useContext(UserContext); - // todo infer default table - // const gptConfig = GetGamePTConfig(game, playtype); - - const [chartIDs, setChartIDs] = useState([]); - const [tableID, setTableID] = useState(null); - const [tableFolders, setTableFolders] = useState([]); - const [folder, setFolder] = useState(null); - const [filter, setFilter] = useState<"folder" | "all" | "highlighted">("all"); - - useEffect(() => { - if (!tableID) { - return; - } - - APIFetchV1(`/games/${game}/${playtype}/tables/${tableID}`).then((r) => { - if (!r.success) { - throw new Error(r.description); - } - - // @ts-expect-error todo - setTableFolders(r.body.folders); - // @ts-expect-error todo - setFolder(r.body.folders[r.body.folders.length - 1]); - }); - }, [tableID]); - - useEffect(() => { - if (filter !== "folder" || !folder) { - setChartIDs(null); - return; - } - - APIFetchV1(`/games/${game}/${playtype}/folders/${folder.folderID}`).then((r) => { - if (!r.success) { - throw new Error(r.description); - } - - // @ts-expect-error todo - setChartIDs(r.body.charts.map((e) => e.chartID)); - }); - }, [folder, tableID, filter]); - const { data, error } = useQuery(`/games/${game}/${playtype}/tables`, async () => { const res = await APIFetchV1(`/games/${game}/${playtype}/tables`); @@ -100,12 +58,6 @@ export default function SessionRaiseBreakdown({ }); const [view, setView] = useState<"lamps" | "both" | "grades">("both"); - useEffect(() => { - if (tableID === null && (data?.length ?? 0) > 0) { - setTableID(data![0].tableID); - } - }, [data]); - if (error) { return <>{(error as Error).message}; } @@ -138,54 +90,6 @@ export default function SessionRaiseBreakdown({
- -
- -
- {filter === "folder" && ( - <> -
- -
-
- -
-
- -
- - )} @@ -196,22 +100,18 @@ export default function SessionRaiseBreakdown({ )} - + ); } function SessionScoreStatBreakdown({ sessionData, - chartIDs, - filter, view, setScores, }: { sessionData: SessionReturns; setScores: SetScores; - chartIDs: string[] | null; - filter: "folder" | "all" | "highlighted"; view: "lamps" | "both" | "grades"; }) { const songMap = CreateSongMap(sessionData.songs); @@ -236,12 +136,6 @@ function SessionScoreStatBreakdown({ continue; } - if (filter === "folder" && chartIDs && !chartIDs.includes(score.chartID)) { - continue; - } else if (filter === "highlighted" && !score.highlight) { - continue; - } - if (scoreInfo.isNewScore) { PartialArrayRecordAssign(newLamps, score.scoreData.lamp, { score, scoreInfo }); PartialArrayRecordAssign(newGrades, score.scoreData.grade, { score, scoreInfo }); @@ -260,7 +154,7 @@ function SessionScoreStatBreakdown({ } return [newLamps, newGrades]; - }, [chartIDs, filter, view]); + }, [view]); const gptConfig = GetGamePTConfig(sessionData.session.game, sessionData.session.playtype); diff --git a/client/src/components/user/UGPTHeader.tsx b/client/src/components/user/UGPTHeader.tsx index ead27d65d..258983103 100644 --- a/client/src/components/user/UGPTHeader.tsx +++ b/client/src/components/user/UGPTHeader.tsx @@ -4,9 +4,8 @@ import NavItem from "components/nav/NavItem"; import MiniTable from "components/tables/components/MiniTable"; import Divider from "components/util/Divider"; import React from "react"; -import { Game, UserDocument } from "tachi-common"; +import { Game, UserDocument, Playtype } from "tachi-common"; import { UGPTStatsReturn } from "types/api-returns"; -import { Playtype } from "tachi-common"; import ProfileBadges from "./ProfileBadges"; import ProfilePicture from "./ProfilePicture"; import RankingData from "./UGPTRankingData"; @@ -86,7 +85,7 @@ export function UGPTBottomNav({ }) { const navItems = [ - Overview + Overview & Activity , Scores diff --git a/client/src/types/api-returns.ts b/client/src/types/api-returns.ts index 54aea8d95..cb130ee01 100644 --- a/client/src/types/api-returns.ts +++ b/client/src/types/api-returns.ts @@ -229,3 +229,5 @@ export interface ActivityReturn { users: Array; } + +export type RecordActivityReturn = Partial>; diff --git a/client/src/util/activity.ts b/client/src/util/activity.ts index 7431be54b..8862125e9 100644 --- a/client/src/util/activity.ts +++ b/client/src/util/activity.ts @@ -1,14 +1,36 @@ -import { ActivityReturn } from "types/api-returns"; +import { ActivityReturn, RecordActivityReturn } 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"; +const SORT_ACTIVITY = NumericSOV((x: ClumpedActivity[0]) => { + switch (x.type) { + case "SESSION": + return x.timeStarted; + case "SCORES": + return x.scores[0]?.timeAchieved ?? -Infinity; + case "CLASS_ACHIEVEMENT": + return x.timeAchieved; + } +}, true); + /** * Given recent activity data, clump it together so it's easier to work with, * by joining repeat-highlighted scores from the same user. + * + * This also supports multiple games, i.e. from /users/:userID/activity. */ -export function ClumpActivity(data: ActivityReturn): ClumpedActivity { +export function ClumpActivity(data: ActivityReturn | RecordActivityReturn): ClumpedActivity { + if (!("songs" in data)) { + const act = []; + for (const activity of Object.values(data)) { + act.push(...ClumpActivity(activity)); + } + + return act.sort(SORT_ACTIVITY); + } + const songMap = CreateSongMap(data.songs); const chartMap = CreateChartMap(data.charts); @@ -68,16 +90,15 @@ export function ClumpActivity(data: ActivityReturn): ClumpedActivity { }); } - 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) - ); + return clumped.sort(SORT_ACTIVITY); +} + +export function GetUsers(data: ActivityReturn | RecordActivityReturn) { + if ("users" in data) { + return data.users; + } + + return Object.values(data) + .map((e) => e.users) + .flat(); } diff --git a/common/src/utils/util.ts b/common/src/utils/util.ts index 4f34da49d..8532e9cd9 100644 --- a/common/src/utils/util.ts +++ b/common/src/utils/util.ts @@ -72,7 +72,12 @@ export function FormatGame(game: Game, playtype: Playtypes[Game]): string { return `${gameConfig.name} (${playtype})`; } -export function FormatChart(game: Game, song: SongDocument, chart: ChartDocument): string { +export function FormatChart( + game: Game, + song: SongDocument, + chart: ChartDocument, + short = false +): string { if (game === "bms") { const tables = (chart as ChartDocument<"bms:7K" | "bms:14K">).data.tableFolders; @@ -124,19 +129,27 @@ export function FormatChart(game: Game, song: SongDocument, chart: ChartDocument const gameConfig = GetGameConfig(game); - let playtypeStr = `${chart.playtype} `; + let playtypeStr = `${chart.playtype}`; if (gameConfig.validPlaytypes.length === 1) { playtypeStr = ""; } + const gptConfig = GetGamePTConfig(game, chart.playtype); + + const diff = short ? gptConfig.shortDifficulties[chart.difficulty] : chart.difficulty; + + // iidx formats things like SPA instead of SP A. + // this is a hack, this should be part of the gptConfig, tbh. + const space = game !== "iidx" ? " " : ""; + // return the most recent version this chart appeared in if it // is not primary. if (!chart.isPrimary) { - return `${song.title} (${playtypeStr}${chart.difficulty} ${chart.level} ${chart.versions[0]})`; + return `${song.title} (${playtypeStr}${space}${diff} ${chart.level} ${chart.versions[0]})`; } - return `${song.title} (${playtypeStr}${chart.difficulty} ${chart.level})`; + return `${song.title} (${playtypeStr}${space}${diff} ${chart.level})`; } export function AbsoluteGradeDelta( diff --git a/server/src/lib/activity/activity.ts b/server/src/lib/activity/activity.ts index 2e80e6250..036f86621 100644 --- a/server/src/lib/activity/activity.ts +++ b/server/src/lib/activity/activity.ts @@ -7,7 +7,9 @@ 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; +export type ActivityConstraint = FilterQuery< + ClassAchievementDocument & ScoreDocument & SessionDocument +>; /** * Retrieves recent activity for this group of users for this GPT. diff --git a/server/src/server/router/api/v1/users/_userID/router.ts b/server/src/server/router/api/v1/users/_userID/router.ts index f455d5820..8850f7278 100644 --- a/server/src/server/router/api/v1/users/_userID/router.ts +++ b/server/src/server/router/api/v1/users/_userID/router.ts @@ -11,9 +11,11 @@ import settingsRouter from "./settings/router"; import { HashPassword, PasswordCompare, ValidatePassword } from "../../auth/auth"; import { Router } from "express"; import db from "external/mongo/db"; +import { GetRecentActivity } from "lib/activity/activity"; import { SYMBOL_TACHI_DATA } from "lib/constants/tachi"; import { ONE_MONTH } from "lib/constants/time"; import CreateLogCtx from "lib/logger/logger"; +import { GetRivalIDs } from "lib/rivals/rivals"; import p from "prudence"; import prValidate from "server/middleware/prudence-validate"; import { DeleteUndefinedProps, IsNonEmptyString, NotNullish, StripUrl } from "utils/misc"; @@ -25,7 +27,8 @@ import { GetRecentSessions, } from "utils/queries/summary"; import { FormatUserDoc, GetAllRankings, GetUserWithID } from "utils/user"; -import type { ImportTypes, integer, UserGameStats } from "tachi-common"; +import type { ActivityConstraint } from "lib/activity/activity"; +import type { IDStrings, ImportTypes, integer, UserGameStats } from "tachi-common"; import type { ProfileRatingAlgs } from "utils/string-checks"; const logger = CreateLogCtx(__filename); @@ -411,6 +414,72 @@ router.get("/recent-imports", async (req, res) => { }); }); +/** + * Fetch this users recent activity, and all of their rivals for each GPT they've played. + * + * @name GET /api/v1/users/:userID/activity + */ +router.use( + "/activity", + prValidate({ + startTime: "*string", + includeRivals: p.optional(p.isIn("true", "false")), + }), + async (req, res) => { + const qStartTime = req.query.startTime as string | undefined; + + const includeRivals = req.query.includeRivals === "true"; + + 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 user = NotNullish(req[SYMBOL_TACHI_DATA]?.requestedUser); + + const gpts = await db["game-stats"].find({ + userID: user.id, + }); + + const data: Partial> = {}; + + await Promise.all( + gpts.map(async (e) => { + let userID: ActivityConstraint["userID"] = user.id; + + if (includeRivals) { + const rivalIDs = await GetRivalIDs(user.id, e.game, e.playtype); + + userID = { $in: [user.id, ...rivalIDs] }; + } + + const activity = await GetRecentActivity( + e.game, + { + game: e.game, + playtype: e.playtype, + userID, + }, + 30, + startTime + ); + + data[`${e.game}:${e.playtype}` as IDStrings] = activity; + }) + ); + + return res.status(200).json({ + success: true, + description: `Returned recent activity.`, + body: data, + }); + } +); + router.use("/games/:game/:playtype", gamePTRouter); router.use("/pfp", pfpRouter); router.use("/banner", bannerRouter);