feat: activity all the things

This commit is contained in:
zkldi
2022-11-11 18:42:34 +00:00
parent 5e4dcaf8a1
commit e565208e6e
14 changed files with 313 additions and 186 deletions
@@ -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 <DashboardNotLoggedIn />;
}
return (
<>
{ugs?.length ? (
<DashboardLoggedIn user={user} />
) : (
<DashboardLoggedInNoScores user={user} />
)}
</>
);
if (ugs?.length === 0) {
return <DashboardLoggedInNoScores user={user} />;
}
return <DashboardLoggedIn user={user} />;
}
function DashboardLoggedIn({ user }: { user: UserDocument }) {
const splash = useMemo(() => RFA(heySplashes), [user]);
return (
<div>
<span className="display-4">
{RFA(heySplashes)}, {user.username}.
{splash}, {user.username}.
</span>
<Divider />
<div className="card mt-4">
<DashboardHeader />
</div>
<RecentInfo user={user} />
<h1>Here's all your profiles.</h1>
<Divider />
<UserGameStatsInfo user={user} />
<Switch>
<Route exact path="/dashboard">
<DashboardActivity user={user} />
</Route>
<Route exact path="/dashboard/profiles">
<UserGameStatsInfo user={user} />
</Route>
</Switch>
</div>
);
}
@@ -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 <AboutMeCard reqUser={reqUser} />;
return (
<>
<AboutMeCard reqUser={reqUser} />
<Divider />
<Activity url={`/users/${reqUser.id}/activity`} />
</>
);
}
function InferLinks(aboutMe: string) {
+4 -2
View File
@@ -67,11 +67,13 @@ export default function DashboardRoutes() {
<Layout>
<DashboardErrorBoundary>
<Switch>
<Route exact path="/dashboard">
{/* this is the easiest (read: least sucky) way of handling */}
{/* these routes */}
<Route exact path={["/dashboard", "/dashboard/profiles"]}>
<DashboardPage />
</Route>
<Route path="/dashboard/privacy">
<Route exact path="/dashboard/privacy">
<PrivacyPolicyPage />
</Route>
-2
View File
@@ -132,8 +132,6 @@ function SongChartRoutes({ game, playtype }: GamePT) {
const { settings } = useContext(UserSettingsContext);
const gptConfig = GetGamePTConfig(game, playtype);
const [activeChart, setActiveChart] = useState<ChartDocument | null>(null);
useEffect(() => {
+114 -34
View File
@@ -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<ClumpedActivity>([]);
const [users, setUsers] = useState<Array<UserDocument>>([]);
const [shouldShowGame, setShouldShowGame] = useState(false);
const { data, error } = useApiQuery<ActivityReturn>(url);
const { data, error } = useApiQuery<ActivityReturn | RecordActivityReturn>(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 (
<ActivityInner
shouldShowGame={shouldShowGame}
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)]);
APIFetchV1<ActivityReturn | RecordActivityReturn>(`${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<UserDocument>;
fetchMoreFrom: (start: number) => void;
shouldShowGame: boolean;
}) {
const userMap = CreateUserMap(users);
@@ -107,11 +120,29 @@ function ActivityInner({
switch (e.type) {
case "SCORES":
return <ScoresActivity data={e} user={user} />;
return (
<ScoresActivity
shouldShowGame={shouldShowGame}
data={e}
user={user}
/>
);
case "SESSION":
return <SessionActivity data={e} user={user} />;
return (
<SessionActivity
shouldShowGame={shouldShowGame}
data={e}
user={user}
/>
);
case "CLASS_ACHIEVEMENT":
return <ClassAchievementActivity data={e} user={user} />;
return (
<ClassAchievementActivity
shouldShowGame={shouldShowGame}
data={e}
user={user}
/>
);
}
})}
<div className="timeline-item">
@@ -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
}}
/>
<span style={{ fontSize: "1.15rem" }} className="ml-2">
{user.username} highlighted {subMessage}!
<UGPTLink reqUser={user} game={game} playtype={playtype} /> highlighted{" "}
{subMessage}!
</span>
{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 */}
<UGPTLink
reqUser={user}
game={data.game}
playtype={data.playtype}
/>{" "}
{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 ? "!" : "."}
</span>
<br />
{data.desc && data.desc !== "This session has no description." && (
@@ -328,7 +393,7 @@ function SessionShower({ sessionID }: { sessionID: string }) {
}
return (
<Row>
<Row className="mt-4">
<SessionRaiseBreakdown sessionData={data} setScores={NO_OP} />
<Col xs={12}>
<Divider />
@@ -348,9 +413,11 @@ function SessionShower({ sessionID }: { sessionID: string }) {
function ClassAchievementActivity({
data,
user,
shouldShowGame,
}: {
data: ClumpedActivityClassAchievement;
user: UserDocument;
shouldShowGame: boolean;
}) {
return (
<div className="timeline-item timeline-hover">
@@ -365,12 +432,7 @@ function ClassAchievementActivity({
>
<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>{" "}
<UGPTLink reqUser={user} game={data.game} playtype={data.playtype} />{" "}
achieved{" "}
<ClassBadge
classSet={data.classSet}
@@ -378,6 +440,7 @@ function ClassAchievementActivity({
playtype={data.playtype}
classValue={data.classValue}
/>
{shouldShowGame && ` in ${FormatGame(data.game, data.playtype)}`}!
{data.classOldValue !== null && (
<>
{" "}
@@ -405,3 +468,20 @@ function ClassAchievementActivity({
</div>
);
}
function UGPTLink({ reqUser, game, playtype }: UGPT) {
// currently
const { user } = useContext(UserContext);
return (
<Link
to={`/dashboard/users/${reqUser.username}/games/${game}/${playtype}`}
className="gentle-link"
style={{
fontWeight: "bold",
}}
>
{user?.id === reqUser.id ? "You" : reqUser.username}
</Link>
);
}
@@ -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 (
<>
<div className="display-4 mb-4">Here's what you and your rivals have been up to!</div>
<Activity url={`/users/${user.id}/activity?includeRivals=true`} />
</>
);
}
@@ -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 = [
<NavItem key="activity" to="/dashboard">
Activity
</NavItem>,
<NavItem key="profiles" to="/dashboard/profiles">
Your Profiles
</NavItem>,
];
return (
<div className="row align-items-center mb-0">
<Navbar>{navItems}</Navbar>
</div>
);
}
@@ -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<string[] | null>([]);
const [tableID, setTableID] = useState<string | null>(null);
const [tableFolders, setTableFolders] = useState<FolderDocument[]>([]);
const [folder, setFolder] = useState<FolderDocument | null>(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<TableDocument[]>(`/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({
</div>
</div>
</div>
<div className="col-lg-3 d-none d-lg-block">
<select
className="form-control ml-auto"
value={filter}
onChange={(e) => setFilter(e.target.value as any)}
>
<option value="all">No Filter</option>
<option value="folder">Folder Filters</option>
<option value="highlighted">Highlights Only</option>
</select>
</div>
{filter === "folder" && (
<>
<div className="col-12">
<Divider className="mt-4 mb-4" />
</div>
<div className="col-6">
<select
className="form-control"
onChange={(e) => setTableID(e.target.value)}
>
{data.map((e) => (
<option key={e.tableID} value={e.tableID}>
{e.title}
</option>
))}
</select>
</div>
<div className="col-6">
<select
className="form-control"
value={folder?.folderID}
onChange={(e) =>
setFolder(
tableFolders.find((f) => f.folderID === e.target.value)!
)
}
>
{tableFolders.map((e) => (
<option key={e.folderID} value={e.folderID}>
{e.title}
</option>
))}
</select>
</div>
</>
)}
</div>
<Divider className="mt-4 mb-4" />
@@ -196,22 +100,18 @@ export default function SessionRaiseBreakdown({
</div>
)}
</div>
<SessionScoreStatBreakdown {...{ sessionData, chartIDs, filter, view, setScores }} />
<SessionScoreStatBreakdown {...{ sessionData, view, setScores }} />
</>
);
}
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);
+2 -3
View File
@@ -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 = [
<NavItem key="overview" to={`${baseUrl}/`}>
Overview
Overview & Activity
</NavItem>,
<NavItem key="scores" to={`${baseUrl}/scores`}>
Scores
+2
View File
@@ -229,3 +229,5 @@ export interface ActivityReturn {
users: Array<UserDocument>;
}
export type RecordActivityReturn = Partial<Record<IDStrings, ActivityReturn>>;
+35 -14
View File
@@ -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();
}
+17 -4
View File
@@ -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<I extends IDStrings = IDStrings>(
+3 -1
View File
@@ -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<ClassAchievementDocument & ScoreDocument & SessionDocument>;
export type ActivityConstraint = FilterQuery<
ClassAchievementDocument & ScoreDocument & SessionDocument
>;
/**
* Retrieves recent activity for this group of users for this GPT.
@@ -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<Record<IDStrings, unknown>> = {};
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);