+
Best Sessions
diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/SpecificSessionPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/SpecificSessionPage.tsx
index d767e68a1..9da1ccea3 100644
--- a/client/src/app/pages/dashboard/users/games/_game/_playtype/SpecificSessionPage.tsx
+++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/SpecificSessionPage.tsx
@@ -15,13 +15,11 @@ import { UserContext } from "context/UserContext";
import React, { useContext, useMemo, useState } from "react";
import { Badge, Button, Col, Form, Modal, Row } from "react-bootstrap";
import { Redirect, useParams } from "react-router-dom";
-import { GetGameConfig, UserDocument, SessionDocument } from "tachi-common";
+import { GetGameConfig, SessionDocument } from "tachi-common";
import { SessionReturns } from "types/api-returns";
-import { GamePT, SetState } from "types/react";
+import { SetState, UGPT } from "types/react";
-type Props = { reqUser: UserDocument } & GamePT;
-
-export default function SpecificSessionPage({ reqUser, game, playtype }: Props) {
+export default function SpecificSessionPage({ reqUser, game, playtype }: UGPT) {
const { sessionID } = useParams<{ sessionID: string }>();
const { data, error } = useApiQuery(`/sessions/${sessionID}`);
@@ -49,7 +47,7 @@ export default function SpecificSessionPage({ reqUser, game, playtype }: Props)
return ;
}
-function SessionPage({ data, game, playtype }: Props & { data: SessionReturns }) {
+function SessionPage({ data, game, playtype }: UGPT & { data: SessionReturns }) {
const [sessionData, setSessionData] = useState(data);
const { session, user, charts, scores, songs } = sessionData;
diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx
index dbdcd437a..80fc52971 100644
--- a/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx
+++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx
@@ -1,4 +1,5 @@
import { APIFetchV1 } from "util/api";
+import { ErrorPage } from "app/pages/ErrorPage";
import ClassBadge from "components/game/ClassBadge";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
@@ -12,8 +13,9 @@ import Muted from "components/util/Muted";
import useApiQuery from "components/util/query/useApiQuery";
import SelectButton from "components/util/SelectButton";
import useQueryString from "components/util/useQueryString";
+import { UGPTContext } from "context/UGPTContext";
import deepmerge from "deepmerge";
-import { FieldArray, useFormik } from "formik";
+import { useFormik } from "formik";
import { TachiConfig } from "lib/config";
import React, { useContext, useEffect, useState } from "react";
import { Button, Col, Form, Row } from "react-bootstrap";
@@ -22,18 +24,14 @@ import {
FormatGame,
GetGameConfig,
GetGamePTConfig,
- UserDocument,
ShowcaseStatDetails,
TableDocument,
UGPTSettings,
+ UserDocument,
} from "tachi-common";
-import { GamePT, SetState } from "types/react";
-import { UGPTContext } from "context/UGPTContext";
-import { ErrorPage } from "app/pages/ErrorPage";
+import { SetState, UGPT } from "types/react";
-type Props = { reqUser: UserDocument } & GamePT;
-
-export default function UGPTSettingsPage({ reqUser, game, playtype }: Props) {
+export default function UGPTSettingsPage({ reqUser, game, playtype }: UGPT) {
const query = useQueryString();
const [page, setPage] = useState<"preferences" | "showcase">(
@@ -47,7 +45,7 @@ export default function UGPTSettingsPage({ reqUser, game, playtype }: Props) {
`${reqUser.username}'s ${FormatGame(game, playtype)} Settings`
);
- const props = { reqUser, game, playtype };
+ const UGPT = { reqUser, game, playtype };
return (
@@ -67,9 +65,9 @@ export default function UGPTSettingsPage({ reqUser, game, playtype }: Props) {
{page === "preferences" ? (
-
+
) : (
-
+
)}
@@ -77,7 +75,7 @@ export default function UGPTSettingsPage({ reqUser, game, playtype }: Props) {
);
}
-function PreferencesForm({ reqUser, game, playtype }: Props) {
+function PreferencesForm({ reqUser, game, playtype }: UGPT) {
const { loggedInData, setLoggedInData } = useContext(UGPTContext);
if (!loggedInData) {
@@ -386,7 +384,7 @@ function PreferencesForm({ reqUser, game, playtype }: Props) {
);
}
-function ShowcaseForm({ reqUser, game, playtype }: Props) {
+function ShowcaseForm({ reqUser, game, playtype }: UGPT) {
const { loggedInData, setLoggedInData } = useContext(UGPTContext);
if (!loggedInData) {
@@ -468,7 +466,7 @@ function RenderCurrentStats({
}: {
stats: ShowcaseStatDetails[];
setStats: SetState
;
-} & Props) {
+} & UGPT) {
function RemoveStatAtIndex(index: number) {
setStats(stats.filter((e, i) => i !== index));
}
diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderSelectPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderSelectPage.tsx
index eefa93033..6edea59df 100644
--- a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderSelectPage.tsx
+++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderSelectPage.tsx
@@ -15,11 +15,9 @@ import { UserContext } from "context/UserContext";
import React, { useContext, useMemo, useState } from "react";
import { FolderDocument, GetGamePTConfig, UserDocument } from "tachi-common";
import { FolderStatsInfo, UGPTFolderSearch } from "types/api-returns";
-import { GamePT } from "types/react";
+import { GamePT, UGPT } from "types/react";
-type Props = { reqUser: UserDocument } & GamePT;
-
-export default function FoldersSearch({ reqUser, game, playtype }: Props) {
+export default function FoldersSearch({ reqUser, game, playtype }: UGPT) {
const [search, setSearch] = useState("");
const params = useMemo(() => new URLSearchParams({ search }), [search]);
@@ -82,7 +80,7 @@ export function FolderInfoComponent({
playtype,
folderStats,
folder,
-}: Props & { folder: FolderDocument; folderStats: FolderStatsInfo }) {
+}: UGPT & { folder: FolderDocument; folderStats: FolderStatsInfo }) {
const gptConfig = GetGamePTConfig(game, playtype);
const scoreBucket = useBucket(game, playtype);
diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.tsx
index c3757b39c..c9fd4dea9 100644
--- a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.tsx
+++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.tsx
@@ -27,9 +27,9 @@ import {
GetGamePTConfig,
UserDocument,
TableDocument,
+ Playtype,
} from "tachi-common";
import { FolderStatsInfo, UGPTTableReturns } from "types/api-returns";
-import { Playtype } from "types/tachi";
interface Props {
reqUser: UserDocument;
diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage.tsx
index ec3d8dedd..99156189e 100644
--- a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage.tsx
+++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage.tsx
@@ -3,20 +3,18 @@ import Divider from "components/util/Divider";
import Icon from "components/util/Icon";
import SelectLinkButton from "components/util/SelectLinkButton";
import useUGPTBase from "components/util/useUGPTBase";
-import { UserContext } from "context/UserContext";
import { AllLUGPTStatsContext } from "context/AllLUGPTStatsContext";
+import { UserContext } from "context/UserContext";
import React, { useContext } from "react";
import { Route, Switch } from "react-router-dom";
-import { FormatGame, GetGameConfig, UserDocument } from "tachi-common";
-import { GamePT } from "types/react";
+import { FormatGame, GetGameConfig } from "tachi-common";
+import { UGPT } from "types/react";
import FolderSelectPage from "./FolderSelectPage";
import FolderTablePage from "./FolderTablePage";
import RecentFoldersPage from "./RecentFoldersPage";
import SpecificFolderPage from "./SpecificFolderPage";
-type Props = { reqUser: UserDocument } & GamePT;
-
-export default function FoldersMainPage({ reqUser, game, playtype }: Props) {
+export default function FoldersMainPage({ reqUser, game, playtype }: UGPT) {
const gameConfig = GetGameConfig(game);
const { user } = useContext(UserContext);
@@ -33,7 +31,7 @@ export default function FoldersMainPage({ reqUser, game, playtype }: Props) {
return (
-
+
{user && ugs?.find((x) => x.game === game && x.playtype === playtype) && (
@@ -52,7 +50,6 @@ export default function FoldersMainPage({ reqUser, game, playtype }: Props) {
Folder Select
-
diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/RecentFoldersPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/RecentFoldersPage.tsx
index 113a95ae8..7d8e9d5f9 100644
--- a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/RecentFoldersPage.tsx
+++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/RecentFoldersPage.tsx
@@ -5,16 +5,12 @@ import { UserContext } from "context/UserContext";
import React, { useContext } from "react";
import { Row } from "react-bootstrap";
import { Link } from "react-router-dom";
-import { FolderDocument, UserDocument, RecentlyViewedFolderDocument } from "tachi-common";
+import { FolderDocument, RecentlyViewedFolderDocument } from "tachi-common";
import { FolderStatsInfo } from "types/api-returns";
-import { GamePT } from "types/react";
+import { UGPT } from "types/react";
import { FolderInfoComponent } from "./FolderSelectPage";
-export default function RecentFoldersPage({
- reqUser,
- game,
- playtype,
-}: { reqUser: UserDocument } & GamePT) {
+export default function RecentFoldersPage({ reqUser, game, playtype }: UGPT) {
const { user } = useContext(UserContext);
if (!user) {
diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/SpecificFolderPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/SpecificFolderPage.tsx
index 93033952e..9bc97bb55 100644
--- a/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/SpecificFolderPage.tsx
+++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/SpecificFolderPage.tsx
@@ -37,7 +37,7 @@ import {
} from "tachi-common";
import { UGPTFolderReturns } from "types/api-returns";
import { FolderDataset } from "types/tables";
-import { Playtype } from "types/tachi";
+import { Playtype } from "tachi-common";
import FolderComparePage from "./FolderComparePage";
interface Props {
diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsActivityPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsActivityPage.tsx
new file mode 100644
index 000000000..ddb513e0c
--- /dev/null
+++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsActivityPage.tsx
@@ -0,0 +1,7 @@
+import Activity from "components/activity/Activity";
+import React from "react";
+import { UGPT } from "types/react";
+
+export default function RivalsActivityPage({ reqUser, game, playtype }: UGPT) {
+ return ;
+}
diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx
index 35eea1a6e..53adb3cc0 100644
--- a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx
+++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsMainPage.tsx
@@ -9,6 +9,7 @@ import React from "react";
import { Col, Row } from "react-bootstrap";
import { Redirect, Route, Switch } from "react-router-dom";
import { FormatGame, Game, GetGameConfig, Playtype, UserDocument } from "tachi-common";
+import RivalsActivityPage from "./RivalsActivityPage";
import RivalsManagePage from "./RivalsManagePage";
export default function RivalsMainPage({
@@ -39,14 +40,14 @@ export default function RivalsMainPage({
return (
-
+
Rival's Bests
- Activity
+ Rival Activity
@@ -63,7 +64,7 @@ export default function RivalsMainPage({
{settings?.rivals.length === 0 && }
- NOT WRITTEN YET
+
+ {isRequestingUser && currentRivals.toString() !== rivals.toString() && (
+ <>
+ {/* kind of a stupid way to check whether the array has changed or not, but who cares. */}
+
+
+
+
+
+ You have unsaved changes!
+
+ {
+ const res = await APIFetchV1(
+ `/users/${reqUser.id}/games/${game}/${playtype}/rivals`,
+ {
+ method: "PUT",
+ body: JSON.stringify({
+ rivalIDs: rivals.map((e) => e.id),
+ }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ },
+ true,
+ true
+ );
+
+ if (res.success) {
+ setCurrentRivals(rivals);
+ setSettings({
+ ...settings,
+ rivals: rivals.map((e) => e.id),
+ });
+ }
+ }}
+ variant="primary"
+ >
+ Save Changes
+
+
+
+ >
+ )}
{rivals.map((e) => (
@@ -144,47 +196,6 @@ function RivalsOverviewPage({
)}
- {/* kind of a stupid way to check whether the array has changed or not, but who cares. */}
- {currentRivals.toString() !== rivals.toString() && (
- <>
-
-
-
-
- {
- const res = await APIFetchV1(
- `/users/${reqUser.id}/games/${game}/${playtype}/rivals`,
- {
- method: "PUT",
- body: JSON.stringify({
- rivalIDs: rivals.map((e) => e.id),
- }),
- headers: {
- "Content-Type": "application/json",
- },
- },
- true,
- true
- );
-
- if (res.success) {
- setCurrentRivals(rivals);
- setSettings({
- ...settings,
- rivals: rivals.map((e) => e.id),
- });
- }
- }}
- variant="primary"
- >
- Save Changes
-
-
- >
- )}
-
{
if (rivals.length >= 5) {
@@ -202,7 +213,7 @@ function RivalsOverviewPage({
setShow={setShow}
url={`/games/${game}/${playtype}/players`}
excludeSet={rivals.map((e) => e.id)}
- excludeMsg="Already Rivaled!"
+ excludeMsg="Added!"
/>
>
)}
diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/TargetsPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/TargetsPage.tsx
index ea3214bb3..727ea9432 100644
--- a/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/TargetsPage.tsx
+++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/targets/TargetsPage.tsx
@@ -6,13 +6,11 @@ import useUGPTBase from "components/util/useUGPTBase";
import React from "react";
import { Col, Row } from "react-bootstrap";
import { Route, Switch } from "react-router-dom";
-import { FormatGame, GetGameConfig, UserDocument } from "tachi-common";
-import { GamePT } from "types/react";
+import { FormatGame, GetGameConfig } from "tachi-common";
+import { UGPT } from "types/react";
import GoalsPage from "./GoalsPage";
-type Props = { reqUser: UserDocument } & GamePT;
-
-export default function TargetsPage({ reqUser, game, playtype }: Props) {
+export default function TargetsPage({ reqUser, game, playtype }: UGPT) {
const gameConfig = GetGameConfig(game);
useSetSubheader(
@@ -26,7 +24,7 @@ export default function TargetsPage({ reqUser, game, playtype }: Props) {
return (
-
+
Goals
diff --git a/client/src/components/activity/Activity.tsx b/client/src/components/activity/Activity.tsx
new file mode 100644
index 000000000..9c244ce25
--- /dev/null
+++ b/client/src/components/activity/Activity.tsx
@@ -0,0 +1,407 @@
+import { CreateUserMap } from "util/data";
+import { NO_OP, TruncateString } from "util/misc";
+import { FormatTime, MillisToSince } from "util/time";
+import { ONE_HOUR } from "util/constants/time";
+import { ClumpActivity } from "util/activity";
+import { APIFetchV1 } from "util/api";
+import SessionRaiseBreakdown from "components/sessions/SessionRaiseBreakdown";
+import ScoreTable from "components/tables/scores/ScoreTable";
+import ApiError from "components/util/ApiError";
+import Divider from "components/util/Divider";
+import Icon from "components/util/Icon";
+import LinkButton from "components/util/LinkButton";
+import Loading from "components/util/Loading";
+import Muted from "components/util/Muted";
+import useApiQuery from "components/util/query/useApiQuery";
+import React, { useEffect, useState } from "react";
+import { Button, Col, Row } from "react-bootstrap";
+import { FormatChart, UserDocument } from "tachi-common";
+import { ActivityReturn, SessionReturns } from "types/api-returns";
+import { ScoreDataset } from "types/tables";
+import {
+ ClumpedActivity,
+ ClumpedActivityClassAchievement,
+ ClumpedActivityScores,
+ ClumpedActivitySession,
+} from "types/tachi";
+import ClassBadge from "components/game/ClassBadge";
+import { Link } from "react-router-dom";
+
+// Records activity for a group of users on a GPT. Also used for single users.
+export default function Activity({
+ url,
+ handleNoActivity = (
+
+ We found no activity!
+
+ ),
+}: {
+ url: string;
+ handleNoActivity?: React.ReactNode;
+}) {
+ const [clumped, setClumped] = useState([]);
+ const [users, setUsers] = useState>([]);
+
+ const { data, error } = useApiQuery(url);
+
+ useEffect(() => {
+ if (!data) {
+ setClumped([]);
+ setUsers([]);
+ } else {
+ setClumped(ClumpActivity(data));
+ setUsers(data.users);
+ }
+ }, [data]);
+
+ if (error) {
+ return ;
+ }
+
+ if (!data) {
+ return ;
+ }
+
+ if (clumped.length === 0) {
+ return <>{handleNoActivity}>;
+ }
+
+ return (
+ {
+ APIFetchV1(`${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;
+ fetchMoreFrom: (start: number) => void;
+}) {
+ const userMap = CreateUserMap(users);
+
+ return (
+
+ 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);
+
+ if (!user) {
+ return
This user doesn't exist? Whoops.
;
+ }
+
+ switch (e.type) {
+ case "SCORES":
+ return
;
+ case "SESSION":
+ return
;
+ case "CLASS_ACHIEVEMENT":
+ return
;
+ }
+ })}
+
+
+
+
+ {
+ 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...
+
+
+
+
+
+
+ );
+}
+
+function ScoresActivity({ data, user }: { data: ClumpedActivityScores; user: UserDocument }) {
+ const { game, playtype } = data.scores[0];
+
+ const [show, setShow] = useState(false);
+
+ let subMessage;
+ let mutedText: string | null | undefined;
+
+ if (data.scores.length === 1) {
+ const score0 = data.scores[0];
+
+ subMessage = `a score on ${FormatChart(
+ score0.game,
+ score0.__related.song,
+ score0.__related.chart
+ )}`;
+
+ if (score0.comment) {
+ mutedText = `"${score0.comment}"`;
+ }
+ } else {
+ subMessage = `${data.scores.length} scores`;
+
+ mutedText = TruncateString(
+ data.scores
+ .map((e) => FormatChart(e.game, e.__related.song, e.__related.chart))
+ .join(", "),
+ 100
+ );
+ }
+
+ const dataset: ScoreDataset = data.scores.map((e, i) => ({
+ ...e,
+ __related: {
+ ...e.__related,
+ index: i,
+ user,
+ },
+ }));
+
+ return (
+
+
+
+
setShow(!show)}
+ >
+
+
+
+ {user.username} highlighted {subMessage}!
+
+ {mutedText && (
+ <>
+
+ {mutedText}
+ >
+ )}
+
+
+
+ {MillisToSince(data.scores[0].timeAchieved ?? 0)}
+
+
+ {FormatTime(data.scores[0].timeAchieved ?? 0)}
+
+
+
+
+ {show && (
+ <>
+
+
+ >
+ )}
+
+
+ );
+}
+
+function SessionActivity({ data, user }: { data: ClumpedActivitySession; user: UserDocument }) {
+ const [show, setShow] = useState(false);
+
+ const isProbablyActive = Date.now() - data.timeEnded < ONE_HOUR;
+
+ return (
+
+
+
+
setShow(!show)}
+ >
+
+
+
+ {user.username} {isProbablyActive ? "is having" : "had"} a session '
+ {data.name}' with {data.scoreInfo.length}{" "}
+ {data.scoreInfo.length === 1 ? "score" : "scores"}.
+
+
+ {data.desc && data.desc !== "This session has no description." && (
+ {data.desc}
+ )}
+
+
+
+ {MillisToSince(data.timeStarted ?? 0)}
+
+
+ {FormatTime(data.timeStarted ?? 0)}
+
+
+
+ {show &&
}
+
+
+ );
+}
+
+function SessionShower({ sessionID }: { sessionID: string }) {
+ const { data, error } = useApiQuery(`/sessions/${sessionID}`);
+
+ if (error) {
+ return ;
+ }
+
+ if (!data) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+ View Full Session
+
+
+
+ );
+}
+
+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/components/imports/ImportInfo.tsx b/client/src/components/imports/ImportInfo.tsx
index f7da9a300..1b76fb8a7 100644
--- a/client/src/components/imports/ImportInfo.tsx
+++ b/client/src/components/imports/ImportInfo.tsx
@@ -24,7 +24,7 @@ import {
UserGameStats,
} from "tachi-common";
import { ScoreDataset } from "types/tables";
-import { Playtype } from "types/tachi";
+import { Playtype } from "tachi-common";
interface Data {
import: ImportDocument;
diff --git a/client/src/components/layout/header/SearchBar.tsx b/client/src/components/layout/header/SearchBar.tsx
index f9b6a8ed8..8ba7b3f14 100644
--- a/client/src/components/layout/header/SearchBar.tsx
+++ b/client/src/components/layout/header/SearchBar.tsx
@@ -8,7 +8,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { Game, GetGameConfig, integer, UserDocument, SongDocument } from "tachi-common";
import { GamePT, JustChildren } from "types/react";
-import { Playtype } from "types/tachi";
+import { Playtype } from "tachi-common";
import QuickTooltip from "../misc/QuickTooltip";
interface SearchReturns {
diff --git a/client/src/components/tables/cells/ScoreCellLarge.tsx b/client/src/components/tables/cells/ScoreCellLarge.tsx
index fd9ce2417..1cf955c6e 100644
--- a/client/src/components/tables/cells/ScoreCellLarge.tsx
+++ b/client/src/components/tables/cells/ScoreCellLarge.tsx
@@ -1,7 +1,7 @@
import { FormatMillions } from "util/misc";
import React from "react";
import { Game, PBScoreDocument, ScoreDocument } from "tachi-common";
-import { Playtype } from "types/tachi";
+import { Playtype } from "tachi-common";
import ScoreCell from "./ScoreCell";
export default function ScoreCellLarge(props: {
diff --git a/client/src/components/tables/dropdowns/GPTDropdownSettings.tsx b/client/src/components/tables/dropdowns/GPTDropdownSettings.tsx
index ea9a32037..0c05b513e 100644
--- a/client/src/components/tables/dropdowns/GPTDropdownSettings.tsx
+++ b/client/src/components/tables/dropdowns/GPTDropdownSettings.tsx
@@ -1,5 +1,5 @@
import { Game } from "tachi-common";
-import { Playtype } from "types/tachi";
+import { Playtype } from "tachi-common";
import { BMSGraphsComponent } from "./components/BMSScoreDropdownParts";
import { IIDXGraphsComponent } from "./components/IIDXScoreDropdownParts";
import { ITGGraphsComponent } from "./components/ITGScoreDropdownParts";
diff --git a/client/src/components/tables/folders/FolderTable.tsx b/client/src/components/tables/folders/FolderTable.tsx
index 6ec8df73d..acc1b4edd 100644
--- a/client/src/components/tables/folders/FolderTable.tsx
+++ b/client/src/components/tables/folders/FolderTable.tsx
@@ -6,7 +6,7 @@ import useScoreRatingAlg from "components/util/useScoreRatingAlg";
import React, { useState } from "react";
import { Game, IDStrings, ScoreCalculatedDataLookup } from "tachi-common";
import { FolderDataset } from "types/tables";
-import { Playtype } from "types/tachi";
+import { Playtype } from "tachi-common";
import DifficultyCell from "../cells/DifficultyCell";
import IndicatorsCell from "../cells/IndicatorsCell";
import RankingCell, { RankingViewMode } from "../cells/RankingCell";
diff --git a/client/src/components/tables/headers/GameHeaders.tsx b/client/src/components/tables/headers/GameHeaders.tsx
index 08e11cf2a..423f1513e 100644
--- a/client/src/components/tables/headers/GameHeaders.tsx
+++ b/client/src/components/tables/headers/GameHeaders.tsx
@@ -17,7 +17,7 @@ import {
RivalChartDataset,
ScoreDataset,
} from "types/tables";
-import { Playtype } from "types/tachi";
+import { Playtype } from "tachi-common";
import SelectableRating from "../components/SelectableRating";
import { Header, ZTableTHProps } from "../components/TachiTable";
diff --git a/client/src/components/tables/history-scores/HistoryScoreTable.tsx b/client/src/components/tables/history-scores/HistoryScoreTable.tsx
index 1de8582f8..0d535c50c 100644
--- a/client/src/components/tables/history-scores/HistoryScoreTable.tsx
+++ b/client/src/components/tables/history-scores/HistoryScoreTable.tsx
@@ -10,7 +10,7 @@ import {
ScoreDocument,
} from "tachi-common";
import { ScoreDataset } from "types/tables";
-import { Playtype } from "types/tachi";
+import { Playtype } from "tachi-common";
import TimestampCell from "../cells/TimestampCell";
import TachiTable from "../components/TachiTable";
import ScoreCoreCells from "../game-core-cells/ScoreCoreCells";
diff --git a/client/src/components/tables/pbs/PBTable.tsx b/client/src/components/tables/pbs/PBTable.tsx
index aadc52f87..29ba45861 100644
--- a/client/src/components/tables/pbs/PBTable.tsx
+++ b/client/src/components/tables/pbs/PBTable.tsx
@@ -5,7 +5,7 @@ import useScoreRatingAlg from "components/util/useScoreRatingAlg";
import React, { useState } from "react";
import { Game, IDStrings, ScoreCalculatedDataLookup } from "tachi-common";
import { PBDataset } from "types/tables";
-import { Playtype } from "types/tachi";
+import { Playtype } from "tachi-common";
import DropdownIndicatorCell from "../cells/DropdownIndicatorCell";
import IndexCell from "../cells/IndexCell";
import RankingCell, { RankingViewMode } from "../cells/RankingCell";
diff --git a/client/src/components/tables/scores/ScoreTable.tsx b/client/src/components/tables/scores/ScoreTable.tsx
index a26714491..d21c2b76b 100644
--- a/client/src/components/tables/scores/ScoreTable.tsx
+++ b/client/src/components/tables/scores/ScoreTable.tsx
@@ -2,9 +2,8 @@ import { NumericSOV, StrSOV } from "util/sorts";
import { CreateDefaultScoreSearchParams } from "util/tables/create-search";
import useScoreRatingAlg from "components/util/useScoreRatingAlg";
import React, { useState } from "react";
-import { Game, IDStrings, integer, ScoreCalculatedDataLookup } from "tachi-common";
+import { Game, IDStrings, integer, ScoreCalculatedDataLookup, Playtype } from "tachi-common";
import { ScoreDataset } from "types/tables";
-import { Playtype } from "types/tachi";
import DifficultyCell from "../cells/DifficultyCell";
import IndicatorsCell from "../cells/IndicatorsCell";
import TimestampCell from "../cells/TimestampCell";
@@ -27,6 +26,7 @@ export default function ScoreTable({
userCol = false,
game,
alg,
+ noTopDisplayStr,
}: {
dataset: ScoreDataset;
pageLen?: integer;
@@ -34,6 +34,7 @@ export default function ScoreTable({
userCol?: boolean;
game: Game;
alg?: ScoreCalculatedDataLookup[I];
+ noTopDisplayStr?: boolean;
}) {
const defaultRating = useScoreRatingAlg(game, playtype);
const [rating, setRating] = useState(alg ?? defaultRating);
@@ -53,6 +54,7 @@ export default function ScoreTable({
return (
(
diff --git a/client/src/components/util/useUGPTBase.tsx b/client/src/components/util/useUGPTBase.tsx
index cbf806d7e..3bc8da7c0 100644
--- a/client/src/components/util/useUGPTBase.tsx
+++ b/client/src/components/util/useUGPTBase.tsx
@@ -1,12 +1,7 @@
import { useMemo } from "react";
-import { UserDocument } from "tachi-common";
-import { GamePT } from "types/react";
+import { UGPT } from "types/react";
-export default function useUGPTBase({
- reqUser,
- game,
- playtype,
-}: { reqUser: UserDocument } & GamePT) {
+export default function useUGPTBase({ reqUser, game, playtype }: UGPT) {
return useMemo(
() => `/dashboard/users/${reqUser.username}/games/${game}/${playtype}`,
[reqUser, game, playtype]
diff --git a/client/src/types/api-returns.ts b/client/src/types/api-returns.ts
index 1cb169955..54aea8d95 100644
--- a/client/src/types/api-returns.ts
+++ b/client/src/types/api-returns.ts
@@ -218,3 +218,14 @@ export interface ImportsReturn {
imports: Array;
users: Array;
}
+
+export interface ActivityReturn {
+ recentSessions: Array;
+
+ songs: Array;
+ charts: Array;
+ recentlyHighlightedScores: Array;
+ achievedClasses: Array;
+
+ users: Array;
+}
diff --git a/client/src/types/react.ts b/client/src/types/react.ts
index 0b8cb330d..4196cc979 100644
--- a/client/src/types/react.ts
+++ b/client/src/types/react.ts
@@ -9,7 +9,7 @@ import {
FormikTouched,
} from "formik";
import React, { Dispatch, SetStateAction } from "react";
-import { Game, Playtypes } from "tachi-common";
+import { Game, Playtype, UserDocument } from "tachi-common";
export interface JustChildren {
children: React.ReactNode;
@@ -87,5 +87,11 @@ export type UseFormik = {
export interface GamePT {
game: Game;
- playtype: Playtypes[Game];
+ playtype: Playtype;
+}
+
+export interface UGPT {
+ reqUser: UserDocument;
+ game: Game;
+ playtype: Playtype;
}
diff --git a/client/src/types/tachi.ts b/client/src/types/tachi.ts
index 046d57806..06f70f102 100644
--- a/client/src/types/tachi.ts
+++ b/client/src/types/tachi.ts
@@ -1,3 +1,24 @@
-import { Game, Playtypes } from "tachi-common";
+import {
+ ChartDocument,
+ ClassAchievementDocument,
+ ScoreDocument,
+ SessionDocument,
+ SongDocument,
+} from "tachi-common";
-export type Playtype = Playtypes[Game];
+export type ClumpedActivityScores = {
+ type: "SCORES";
+ scores: Array;
+};
+
+export type ClumpedActivitySession = {
+ type: "SESSION";
+} & SessionDocument;
+
+export type ClumpedActivityClassAchievement = {
+ type: "CLASS_ACHIEVEMENT";
+} & ClassAchievementDocument;
+
+export type ClumpedActivity = Array<
+ ClumpedActivitySession | ClumpedActivityScores | ClumpedActivityClassAchievement
+>;
diff --git a/client/src/util/activity.ts b/client/src/util/activity.ts
new file mode 100644
index 000000000..7431be54b
--- /dev/null
+++ b/client/src/util/activity.ts
@@ -0,0 +1,83 @@
+import { ActivityReturn } from "types/api-returns";
+import { ClumpedActivity, ClumpedActivityScores } from "types/tachi";
+import { ONE_HOUR } from "./constants/time";
+import { CreateSongMap, CreateChartMap } from "./data";
+import { NumericSOV } from "./sorts";
+
+/**
+ * Given recent activity data, clump it together so it's easier to work with,
+ * by joining repeat-highlighted scores from the same user.
+ */
+export function ClumpActivity(data: ActivityReturn): ClumpedActivity {
+ const songMap = CreateSongMap(data.songs);
+ const chartMap = CreateChartMap(data.charts);
+
+ const clumped: ClumpedActivity = [];
+
+ let clump: ClumpedActivityScores["scores"] = [];
+ let curUserID: number | null = null;
+ let lastTime: number | null = null;
+
+ for (const score of data.recentlyHighlightedScores
+ .slice(0)
+ .sort(NumericSOV((x) => x.timeAchieved ?? -Infinity, true))) {
+ const song = songMap.get(score.songID);
+ const chart = chartMap.get(score.chartID);
+
+ if (!song || !chart) {
+ console.warn(`Failed to resolve song or chart ${score.songID}, ${chart?.chartID}.`);
+ continue;
+ }
+
+ if (
+ (curUserID !== score.userID && curUserID !== null) ||
+ (lastTime !== null && lastTime - (score.timeAchieved ?? 0) > ONE_HOUR * 8)
+ ) {
+ clumped.push({ type: "SCORES", scores: clump });
+
+ clump = [];
+ }
+
+ curUserID = score.userID;
+ lastTime = score.timeAchieved;
+
+ clump.push({
+ ...score,
+ __related: {
+ song,
+ chart,
+ },
+ });
+ }
+
+ if (clump.length !== 0) {
+ clumped.push({ type: "SCORES", scores: clump });
+ }
+
+ for (const session of data.recentSessions) {
+ clumped.push({
+ type: "SESSION",
+ ...session,
+ });
+ }
+
+ for (const ach of data.achievedClasses) {
+ clumped.push({
+ type: "CLASS_ACHIEVEMENT",
+ ...ach,
+ });
+ }
+
+ return clumped.sort(
+ NumericSOV((x) => {
+ switch (x.type) {
+ case "SESSION":
+ return x.timeStarted;
+ case "SCORES":
+ return x.scores[0]?.timeAchieved ?? -Infinity;
+ case "CLASS_ACHIEVEMENT":
+ return x.timeAchieved;
+ }
+ }, true)
+ );
+}
diff --git a/client/src/util/misc.ts b/client/src/util/misc.ts
index 7234f1660..dff85b12d 100644
--- a/client/src/util/misc.ts
+++ b/client/src/util/misc.ts
@@ -12,8 +12,8 @@ import {
ScoreCalculatedDataLookup,
ScoreDocument,
SessionDocument,
+ Playtype,
} from "tachi-common";
-import { Playtype } from "types/tachi";
import fjsh from "fast-json-stable-hash";
export function RFA(arr: T[]): T {
diff --git a/client/src/util/tables/create-search.ts b/client/src/util/tables/create-search.ts
index 3f611b100..52ca170fa 100644
--- a/client/src/util/tables/create-search.ts
+++ b/client/src/util/tables/create-search.ts
@@ -9,7 +9,7 @@ import {
IDStrings,
} from "tachi-common";
import { ComparePBsDataset, FolderDataset, PBDataset, ScoreDataset } from "types/tables";
-import { Playtype } from "types/tachi";
+import { Playtype } from "tachi-common";
function GetBMSTableVal(chart: ChartDocument<"bms:7K" | "bms:14K">, key: string) {
for (const table of chart.data.tableFolders) {
diff --git a/client/src/util/tierlist.ts b/client/src/util/tierlist.ts
index d137e63bb..5c7a04122 100644
--- a/client/src/util/tierlist.ts
+++ b/client/src/util/tierlist.ts
@@ -1,5 +1,5 @@
import { Game, GPTTierlists, IDStrings, IIDX_LAMPS, ScoreDocument, SDVX_LAMPS } from "tachi-common";
-import { Playtype } from "types/tachi";
+import { Playtype } from "tachi-common";
type ScaleAchievedFns = {
[I in IDStrings]: {
diff --git a/server/src/lib/activity/activity.ts b/server/src/lib/activity/activity.ts
new file mode 100644
index 000000000..2e80e6250
--- /dev/null
+++ b/server/src/lib/activity/activity.ts
@@ -0,0 +1,166 @@
+import db from "external/mongo/db";
+import { GetRelevantSongsAndCharts } from "utils/db";
+import { DedupeArr } from "utils/misc";
+import { GetGPT } from "utils/req-tachi-data";
+import { GetUsersWithIDs } from "utils/user";
+import type { Request, Response } from "express-serve-static-core";
+import type { FilterQuery } from "mongodb";
+import type { ClassAchievementDocument, Game, ScoreDocument, SessionDocument } from "tachi-common";
+
+type ActivityConstraint = FilterQuery;
+
+/**
+ * Retrieves recent activity for this group of users for this GPT.
+ *
+ * At the moment, this retrieves the following events:
+ * - Recent Sessions
+ * - Recent Highlighted Scores
+ * - Achieved Classes
+ *
+ * It will support:
+ * - Recently achieved goals
+ * - Recently achieved quests
+ *
+ * To get the set of things we want to fetch, we fetch the first N sessions we see.
+ * This sets our "upper bound" for how far back we want to look -- when the Nth session
+ * started.
+ *
+ * Optionally, startFrom can be passed, which will start this activity search from that
+ * point in time.
+ */
+export async function GetRecentActivity(
+ // todo: is it possible to make this game agnostic?
+ game: Game,
+ query: ActivityConstraint,
+ sessions = 30,
+ startFrom: number | null = null
+) {
+ const baseQuery = query;
+
+ const initialSessionQuery = {
+ ...baseQuery,
+
+ // start from anytime if startFrom is omitted, otherwise, cap at the start.
+ timeStarted: { $lt: startFrom === null ? Infinity : startFrom },
+ };
+
+ const recentSessions = await db.sessions.find(initialSessionQuery, {
+ sort: {
+ timeStarted: -1,
+ },
+ limit: sessions,
+ });
+
+ // find the earliest point in the sessions we just fetched.
+ // if we found no sessions, set this to now, which means we'll fetch no highlighted
+ // scores.
+ // (it's not possible to have no sessions *and* have scores with timestamps)
+ const earliestSession = recentSessions.at(-1)?.timeStarted ?? Date.now();
+
+ const timeConstraint =
+ startFrom !== null
+ ? { $lt: startFrom, $gte: earliestSession }
+ : {
+ $gte: earliestSession,
+ };
+
+ const achievedClasses = await db["class-achievements"].find(
+ {
+ ...baseQuery,
+ timeAchieved: timeConstraint,
+ },
+ {
+ sort: {
+ timeAchieved: -1,
+ },
+ }
+ );
+
+ const recentlyHighlightedScores = await db.scores.find(
+ {
+ ...baseQuery,
+ highlight: true,
+ timeAchieved: timeConstraint,
+ },
+ {
+ sort: {
+ timeAchieved: -1,
+ },
+ }
+ );
+
+ const { songs, charts } = await GetRelevantSongsAndCharts(recentlyHighlightedScores, game);
+
+ const userIDs = DedupeArr([
+ ...recentSessions.map((e) => e.userID),
+ ...recentlyHighlightedScores.map((e) => e.userID),
+ ...achievedClasses.map((e) => e.userID),
+ ]);
+
+ const users = await GetUsersWithIDs(userIDs);
+
+ return {
+ recentSessions,
+ recentlyHighlightedScores,
+ songs,
+ charts,
+ achievedClasses,
+ users,
+ };
+}
+
+/**
+ * Utility for creating an express handler for activity-related endpoints. These endpoints
+ * are all *remarkably* similar, but with slightly different initial constraints.
+ *
+ * This creates a function that you should call inside another route.
+ */
+export function CreateActivityRouteHandler(query: ActivityConstraint) {
+ return async (req: Request, res: Response) => {
+ const { game } = GetGPT(req);
+
+ const qSessions = req.query.sessions;
+ const qStartTime = req.query.startTime;
+
+ if (qSessions !== undefined && typeof qSessions !== "string") {
+ return res.status(400).json({
+ success: false,
+ description: `Invalid 'sessions'.`,
+ });
+ }
+
+ if (qStartTime !== undefined && typeof qStartTime !== "string") {
+ return res.status(400).json({
+ success: false,
+ description: `Invalid 'startTime'.`,
+ });
+ }
+
+ // defaulting to 30 seems sensible.
+ const sessions = qSessions ? Number(qSessions) : 30;
+
+ if (sessions > 100 || sessions < 10 || Number.isNaN(sessions)) {
+ return res.status(400).json({
+ success: false,
+ description: `Invalid sessions, got ${sessions}, which wasn't between 10 and 100.`,
+ });
+ }
+
+ const startTime = qStartTime ? Number(qStartTime) : null;
+
+ if (Number.isNaN(startTime)) {
+ return res.status(400).json({
+ success: false,
+ description: `Invalid startTime, got a non number.`,
+ });
+ }
+
+ const recentActivity = await GetRecentActivity(game, query, sessions, startTime);
+
+ return res.status(200).json({
+ success: true,
+ description: `Retrieved activity.`,
+ body: recentActivity,
+ });
+ };
+}
diff --git a/server/src/lib/rivals/rivals.ts b/server/src/lib/rivals/rivals.ts
index 94b68a709..5a583732e 100644
--- a/server/src/lib/rivals/rivals.ts
+++ b/server/src/lib/rivals/rivals.ts
@@ -6,7 +6,7 @@ import { FormatGame } from "tachi-common";
import { ArrayDiff } from "utils/misc";
import { GetUsersWithIDs, GetUserWithIDGuaranteed } from "utils/user";
import type { BulkWriteUpdateOneOperation } from "mongodb";
-import type { Game, integer, Playtype, PBScoreDocument } from "tachi-common";
+import type { Game, integer, PBScoreDocument, Playtype } from "tachi-common";
const logger = CreateLogCtx(__filename);
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 95ae0519e..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,6 +1,7 @@
import { RequireAuthedAsUser } from "../../../../middleware";
import { Router } from "express";
import db from "external/mongo/db";
+import { CreateActivityRouteHandler } from "lib/activity/activity";
import { SetRivalsFailReasons } from "lib/constants/err-codes";
import { GetChallengerUsers, GetRivalIDs, GetRivalUsers, SetRivals } from "lib/rivals/rivals";
import p from "prudence";
@@ -167,4 +168,24 @@ router.get("/pb-leaderboard", async (req, res) => {
});
});
+/**
+ * Retrieve activity for this user's set of rivals.
+ *
+ * @name GET /api/v1/users/:userID/games/:game/:playtype/rivals/activity
+ */
+router.get("/activity", async (req, res) => {
+ const { game, playtype, user } = GetUGPT(req);
+
+ const rivalIDs = await GetRivalIDs(user.id, game, playtype);
+
+ const route = CreateActivityRouteHandler({
+ userID: { $in: rivalIDs },
+ game,
+ playtype,
+ });
+
+ // this handles responding
+ void route(req, res);
+});
+
export default router;
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 f5d57b5bc..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,6 +10,7 @@ import tablesRouter from "./tables/router";
import targetsRouter from "./targets/router";
import { Router } from "express";
import db from "external/mongo/db";
+import { CreateActivityRouteHandler } from "lib/activity/activity";
import { ONE_MONTH, ONE_YEAR } from "lib/constants/time";
import p from "prudence";
import prValidate from "server/middleware/prudence-validate";
@@ -311,6 +312,24 @@ router.get("/leaderboard-adjacent", async (req, res) => {
});
});
+/**
+ * Retrieve activity for this user.
+ *
+ * @name GET /api/v1/users/:userID/games/:game/:playtype/activity
+ */
+router.get("/activity", (req, res) => {
+ const { game, playtype, user } = GetUGPT(req);
+
+ const route = CreateActivityRouteHandler({
+ userID: user.id,
+ game,
+ playtype,
+ });
+
+ // this handles responding
+ void route(req, res);
+});
+
router.use("/pbs", pbsRouter);
router.use("/scores", scoresRouter);
router.use("/sessions", sessionsRouter);
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 7a5cce78b..f455d5820 100644
--- a/server/src/server/router/api/v1/users/_userID/router.ts
+++ b/server/src/server/router/api/v1/users/_userID/router.ts
@@ -379,6 +379,8 @@ router.post(
/**
* Get the recent import types this user has used.
+ *
+ * @name GET /api/v1/users/:userID/recent-imports
*/
router.get("/recent-imports", async (req, res) => {
const user = NotNullish(req[SYMBOL_TACHI_DATA]?.requestedUser);