From fef5e3fc01ba8644a5420077dfd84acc057f8e82 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Thu, 10 Nov 2022 20:22:37 +0000 Subject: [PATCH 1/7] refactor: create UGPT common type --- .../_game/_playtype/LeaderboardsPage.tsx | 8 ++---- .../games/_game/_playtype/OverviewPage.tsx | 12 +++------ .../games/_game/_playtype/ScoresPage.tsx | 4 +-- .../_game/_playtype/SpecificSessionPage.tsx | 10 +++---- .../_game/_playtype/UGPTSettingsPage.tsx | 26 +++++++++---------- .../_playtype/folders/FolderSelectPage.tsx | 8 +++--- .../_playtype/folders/FoldersMainPage.tsx | 10 +++---- .../_playtype/folders/RecentFoldersPage.tsx | 10 +++---- .../_playtype/rivals/RivalsActivityPage.tsx | 6 +++++ .../_game/_playtype/rivals/RivalsMainPage.tsx | 3 ++- .../_game/_playtype/targets/TargetsPage.tsx | 6 ++--- .../src/components/user/UGPTStatContainer.tsx | 6 ++--- .../src/components/user/UGPTStatShowcase.tsx | 8 ++---- client/src/components/util/useUGPTBase.tsx | 8 ++---- client/src/types/react.ts | 10 +++++-- 15 files changed, 58 insertions(+), 77 deletions(-) create mode 100644 client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsActivityPage.tsx diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/LeaderboardsPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/LeaderboardsPage.tsx index 1f80b68ec..1c233415d 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/LeaderboardsPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/LeaderboardsPage.tsx @@ -24,18 +24,14 @@ import { } from "tachi-common"; import { GameClassSets } from "tachi-common/game-classes"; import { GPTLeaderboard, UGPTLeaderboardAdjacent } from "types/api-returns"; -import { GamePT, SetState } from "types/react"; +import { GamePT, SetState, UGPT } from "types/react"; interface LeaderboardsData { stats: UGPTLeaderboardAdjacent; leaderboard: GPTLeaderboard; } -export default function LeaderboardsPage({ - reqUser, - game, - playtype, -}: { reqUser: UserDocument } & GamePT) { +export default function LeaderboardsPage({ reqUser, game, playtype }: UGPT) { const gameConfig = GetGameConfig(game); useSetSubheader( ["Users", reqUser.username, "Games", gameConfig.name, playtype, "Leaderboard"], diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx index dcf91597c..d36755104 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx @@ -29,7 +29,7 @@ import { UserGameStats, } from "tachi-common"; import { SessionReturns, UGPTHistory } from "types/api-returns"; -import { GamePT, SetState } from "types/react"; +import { GamePT, SetState, UGPT } from "types/react"; import { ScoreDataset } from "types/tables"; import SessionRaiseBreakdown from "components/sessions/SessionRaiseBreakdown"; import useApiQuery from "components/util/query/useApiQuery"; @@ -37,11 +37,7 @@ import ApiError from "components/util/ApiError"; import Loading from "components/util/Loading"; import Select from "components/util/Select"; -export default function OverviewPage({ - reqUser, - game, - playtype, -}: { reqUser: UserDocument } & GamePT) { +export default function OverviewPage({ reqUser, game, playtype }: UGPT) { const gameConfig = GetGameConfig(game); useSetSubheader( ["Users", reqUser.username, "Games", gameConfig.name, playtype], @@ -58,7 +54,7 @@ export default function OverviewPage({ ); } -function LastSession({ reqUser, game, playtype }: { reqUser: UserDocument } & GamePT) { +function LastSession({ reqUser, game, playtype }: UGPT) { return ( { @@ -255,7 +251,7 @@ function RecentSessionScoreInfo({ type RankingDurations = "3mo" | "year"; -function RankingInfo({ reqUser, game, playtype }: { reqUser: UserDocument } & GamePT) { +function RankingInfo({ reqUser, game, playtype }: UGPT) { const [duration, setDuration] = useState("3mo"); const { data, error } = useApiQuery( diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx index 1d3e15c08..e20aff3de 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx @@ -26,7 +26,7 @@ import { SongDocument, UnsuccessfulAPIResponse, } from "tachi-common"; -import { GamePT, SetState } from "types/react"; +import { GamePT, SetState, UGPT } from "types/react"; import usePreferredRanking from "components/util/usePreferredRanking"; export default function ScoresPage({ @@ -286,7 +286,7 @@ function PBsSearch({ ); } -function ScoresOverview({ reqUser, game, playtype }: { reqUser: UserDocument } & GamePT) { +function ScoresOverview({ reqUser, game, playtype }: UGPT) { const [search, setSearch] = useState(""); const { data, error } = useFetchScores( 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/FoldersMainPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage.tsx index ec3d8dedd..e9f7318fb 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); 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/rivals/RivalsActivityPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsActivityPage.tsx new file mode 100644 index 000000000..5dc4403a6 --- /dev/null +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsActivityPage.tsx @@ -0,0 +1,6 @@ +import React from "react"; +import { UGPT } from "types/react"; + +export default function RivalsActivityPage({ reqUser, game, playtype }: UGPT) { + return
RivalsActivityPage
; +} 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..a09328a1d 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({ @@ -63,7 +64,7 @@ export default function RivalsMainPage({ {settings?.rivals.length === 0 && } - NOT WRITTEN YET + `/dashboard/users/${reqUser.username}/games/${game}/${playtype}`, [reqUser, game, playtype] 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; } From 2a32fb5db2c1cc793202321affd732d9c824a3f9 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Fri, 11 Nov 2022 02:50:32 +0000 Subject: [PATCH 2/7] feat: rival activity --- .../games/_game/_playtype/ActivityPage.tsx | 35 +++ .../_playtype/folders/FolderTablePage.tsx | 2 +- .../_playtype/folders/SpecificFolderPage.tsx | 2 +- .../_playtype/rivals/RivalsActivityPage.tsx | 33 ++- .../_game/_playtype/rivals/RivalsMainPage.tsx | 2 +- .../_playtype/rivals/RivalsManagePage.tsx | 99 +++---- .../_game/_playtype/targets/TargetsPage.tsx | 4 +- client/src/components/imports/ImportInfo.tsx | 2 +- .../components/layout/header/SearchBar.tsx | 2 +- .../tables/cells/ScoreCellLarge.tsx | 2 +- .../tables/dropdowns/GPTDropdownSettings.tsx | 2 +- .../components/tables/folders/FolderTable.tsx | 2 +- .../components/tables/headers/GameHeaders.tsx | 2 +- .../history-scores/HistoryScoreTable.tsx | 2 +- client/src/components/tables/pbs/PBTable.tsx | 2 +- .../components/tables/scores/ScoreTable.tsx | 6 +- .../tables/sessions/GenericSessionTable.tsx | 2 +- client/src/components/user/UGPTActivity.tsx | 242 ++++++++++++++++++ client/src/components/user/UGPTHeader.tsx | 2 +- .../src/components/user/UGPTStatContainer.tsx | 8 +- .../src/components/user/UGPTStatCreator.tsx | 2 +- .../src/components/user/UGPTStatShowcase.tsx | 2 +- client/src/components/util/useBucket.tsx | 2 +- .../src/components/util/useScoreRatingAlg.tsx | 2 +- client/src/components/util/useUGPTBase.tsx | 3 +- client/src/types/api-returns.ts | 10 + client/src/types/tachi.ts | 14 +- client/src/util/activity.ts | 70 +++++ client/src/util/misc.ts | 2 +- client/src/util/tables/create-search.ts | 2 +- client/src/util/tierlist.ts | 2 +- server/src/lib/activity/activity.ts | 67 +++++ server/src/lib/rivals/rivals.ts | 14 +- .../games/_game/_playtype/rivals/router.ts | 49 +++- 34 files changed, 613 insertions(+), 81 deletions(-) create mode 100644 client/src/app/pages/dashboard/users/games/_game/_playtype/ActivityPage.tsx create mode 100644 client/src/components/user/UGPTActivity.tsx create mode 100644 client/src/util/activity.ts create mode 100644 server/src/lib/activity/activity.ts diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/ActivityPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/ActivityPage.tsx new file mode 100644 index 000000000..b5ab0702c --- /dev/null +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/ActivityPage.tsx @@ -0,0 +1,35 @@ +import { ClumpActivity } from "util/activity"; +import ApiError from "components/util/ApiError"; +import Loading from "components/util/Loading"; +import useApiQuery from "components/util/query/useApiQuery"; +import React, { useState } from "react"; +import { ActivityReturn } from "types/api-returns"; +import { UGPT } from "types/react"; +import UGPTActivity from "components/user/UGPTActivity"; + +export default function ActivityPage({ reqUser, game, playtype }: UGPT) { + const [duration, setDuration] = useState<"month" | "3month" | "year">("month"); + + const { data, error } = useApiQuery( + `/users/${reqUser.id}/games/${game}/${playtype}/activity?duration=${duration}` + ); + + if (error) { + return ; + } + + if (!data) { + return ; + } + + const joined = ClumpActivity(data); + + return ( + + ); +} 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/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 index 5dc4403a6..3199a17bd 100644 --- 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 @@ -1,6 +1,35 @@ -import React from "react"; +import { ClumpActivity } from "util/activity"; +import ApiError from "components/util/ApiError"; +import Loading from "components/util/Loading"; +import useApiQuery from "components/util/query/useApiQuery"; +import React, { useState } from "react"; +import { ActivityReturn } from "types/api-returns"; import { UGPT } from "types/react"; +import UGPTActivity from "components/user/UGPTActivity"; export default function RivalsActivityPage({ reqUser, game, playtype }: UGPT) { - return
RivalsActivityPage
; + const [duration, setDuration] = useState<"month" | "3month" | "year">("month"); + + const { data, error } = useApiQuery( + `/users/${reqUser.id}/games/${game}/${playtype}/rivals/activity?duration=${duration}` + ); + + if (error) { + return ; + } + + if (!data) { + return ; + } + + const joined = ClumpActivity(data); + + 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 a09328a1d..94c479e55 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 @@ -47,7 +47,7 @@ export default function RivalsMainPage({ - Activity + Rival Activity diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsManagePage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsManagePage.tsx index 6298cfd15..9c20916f4 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsManagePage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/rivals/RivalsManagePage.tsx @@ -1,5 +1,6 @@ import { APIFetchV1 } from "util/api"; import { SendErrorToast } from "util/toaster"; +import { ChangeOpacity } from "util/color-opacity"; import Card from "components/layout/page/Card"; import ApiError from "components/util/ApiError"; import Divider from "components/util/Divider"; @@ -11,8 +12,8 @@ import useApiQuery from "components/util/query/useApiQuery"; import UserIcon from "components/util/UserIcon"; import { UserContext } from "context/UserContext"; import React, { useContext, useState } from "react"; -import { Button, Col } from "react-bootstrap"; -import { FormatGame, Game, GetGameConfig, Playtype, UserDocument } from "tachi-common"; +import { Alert, Button, Col } from "react-bootstrap"; +import { COLOUR_SET, FormatGame, Game, GetGameConfig, Playtype, UserDocument } from "tachi-common"; import useLUGPTSettings from "components/util/useLUGPTSettings"; import useSetSubheader from "components/layout/header/useSetSubheader"; @@ -105,6 +106,57 @@ function RivalsOverviewPage({ return ( <> + {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!
+ + +
+ + + )} {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() && ( - <> - - - - - - - - )} - { 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 6e6e0b69b..aa01bf178 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,8 +6,8 @@ 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, UGPT } from "types/react"; +import { FormatGame, GetGameConfig } from "tachi-common"; +import { UGPT } from "types/react"; import GoalsPage from "./GoalsPage"; export default function TargetsPage({ reqUser, game, playtype }: UGPT) { 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 ( ; + duration: "month" | "3month" | "year"; + setDuration: SetState<"month" | "3month" | "year">; +}) { + const rivalMap = CreateUserMap(users); + + return ( + <> + + +
+
+ {data.map((e) => { + const rival = rivalMap.get( + e.type === "SCORES" ? e.scores[0]?.userID : e.userID + ); + + if (!rival) { + return
This user doesn't exist? Whoops.
; + } + + return e.type === "SCORES" ? ( + + ) : ( + + ); + })} +
+
+
+
+ This is the end of the recent activity! +
+
+
+
+ + + ); +} + +function ScoresActivity({ data, rival }: { data: ClumpedActivityScores; rival: UserDocument }) { + const { game, playtype } = data.scores[0]; + + const [show, setShow] = useState(false); + + let subMessage; + + if (data.scores.length === 1) { + const score0 = data.scores[0]; + + subMessage = `a score on ${FormatChart( + score0.game, + score0.__related.song, + score0.__related.chart + )}`; + } else { + subMessage = `${data.scores.length} scores`; + } + + const dataset: ScoreDataset = data.scores.map((e, i) => ({ + ...e, + __related: { + ...e.__related, + index: i, + user: rival, + }, + })); + + return ( +
+
+
+
setShow(!show)} + > +
+ + + {rival.username} highlighted {subMessage}! + +
+ +
+ {MillisToSince(data.scores[0].timeAchieved ?? 0)} +
+ + {FormatTime(data.scores[0].timeAchieved ?? 0)} + +
+
+ + {show && ( + <> + + + + )} +
+
+ ); +} + +function SessionActivity({ + data, + rival, +}: { + data: { type: "SESSION" } & SessionDocument; + rival: UserDocument; +}) { + const [show, setShow] = useState(false); + + return ( +
+
+
+
setShow(!show)} + > +
+ + + {rival.username} had a session '{data.name}' with{" "} + {data.scoreInfo.length}{" "} + {data.scoreInfo.length === 1 ? "score" : "scores"}. + +
+ {data.desc && {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 + +
+
+ ); +} diff --git a/client/src/components/user/UGPTHeader.tsx b/client/src/components/user/UGPTHeader.tsx index 96f450c8b..ead27d65d 100644 --- a/client/src/components/user/UGPTHeader.tsx +++ b/client/src/components/user/UGPTHeader.tsx @@ -6,7 +6,7 @@ import Divider from "components/util/Divider"; import React from "react"; import { Game, UserDocument } from "tachi-common"; import { UGPTStatsReturn } from "types/api-returns"; -import { Playtype } from "types/tachi"; +import { Playtype } from "tachi-common"; import ProfileBadges from "./ProfileBadges"; import ProfilePicture from "./ProfilePicture"; import RankingData from "./UGPTRankingData"; diff --git a/client/src/components/user/UGPTStatContainer.tsx b/client/src/components/user/UGPTStatContainer.tsx index 134068ed4..1dcd5ec7b 100644 --- a/client/src/components/user/UGPTStatContainer.tsx +++ b/client/src/components/user/UGPTStatContainer.tsx @@ -1,12 +1,10 @@ import { APIFetchV1 } from "util/api"; import Loading from "components/util/Loading"; -import useApiQuery from "components/util/query/useApiQuery"; -import { UserContext } from "context/UserContext"; -import React, { useContext } from "react"; +import React from "react"; import { useQuery } from "react-query"; -import { integer, UserDocument, ShowcaseStatDetails } from "tachi-common"; +import { integer, ShowcaseStatDetails } from "tachi-common"; import { UGPTPreferenceStatsReturn } from "types/api-returns"; -import { GamePT, UGPT } from "types/react"; +import { UGPT } from "types/react"; import { StatDisplay } from "./UGPTStatShowcase"; export default function UGPTStatContainer({ diff --git a/client/src/components/user/UGPTStatCreator.tsx b/client/src/components/user/UGPTStatCreator.tsx index 35ccd2a23..84360959b 100644 --- a/client/src/components/user/UGPTStatCreator.tsx +++ b/client/src/components/user/UGPTStatCreator.tsx @@ -12,7 +12,7 @@ import { ShowcaseStatDetails, } from "tachi-common"; import { GamePT, SetState } from "types/react"; -import { Playtype } from "types/tachi"; +import { Playtype } from "tachi-common"; import { SongChartsSearch } from "types/api-returns"; import DebounceSearch from "components/util/DebounceSearch"; import Muted from "components/util/Muted"; diff --git a/client/src/components/user/UGPTStatShowcase.tsx b/client/src/components/user/UGPTStatShowcase.tsx index 3b3b95396..333630e47 100644 --- a/client/src/components/user/UGPTStatShowcase.tsx +++ b/client/src/components/user/UGPTStatShowcase.tsx @@ -26,7 +26,7 @@ import { } from "tachi-common"; import { UGPTPreferenceStatsReturn } from "types/api-returns"; import { GamePT, UGPT } from "types/react"; -import { Playtype } from "types/tachi"; +import { Playtype } from "tachi-common"; import UGPTStatContainer from "./UGPTStatContainer"; import UGPTStatCreator from "./UGPTStatCreator"; diff --git a/client/src/components/util/useBucket.tsx b/client/src/components/util/useBucket.tsx index 3a63c977b..ce6fa688d 100644 --- a/client/src/components/util/useBucket.tsx +++ b/client/src/components/util/useBucket.tsx @@ -1,5 +1,5 @@ import { Game, GetGamePTConfig } from "tachi-common"; -import { Playtype } from "types/tachi"; +import { Playtype } from "tachi-common"; import useLUGPTSettings from "./useLUGPTSettings"; export function useBucket(game: Game, playtype: Playtype) { diff --git a/client/src/components/util/useScoreRatingAlg.tsx b/client/src/components/util/useScoreRatingAlg.tsx index 9f6e80c1a..f5782ab65 100644 --- a/client/src/components/util/useScoreRatingAlg.tsx +++ b/client/src/components/util/useScoreRatingAlg.tsx @@ -6,7 +6,7 @@ import { SessionCalculatedDataLookup, UGSRatingsLookup, } from "tachi-common"; -import { Playtype } from "types/tachi"; +import { Playtype } from "tachi-common"; import useLUGPTSettings from "./useLUGPTSettings"; export default function useScoreRatingAlg( diff --git a/client/src/components/util/useUGPTBase.tsx b/client/src/components/util/useUGPTBase.tsx index 191a9d89f..3bc8da7c0 100644 --- a/client/src/components/util/useUGPTBase.tsx +++ b/client/src/components/util/useUGPTBase.tsx @@ -1,6 +1,5 @@ import { useMemo } from "react"; -import { UserDocument } from "tachi-common"; -import { GamePT, UGPT } from "types/react"; +import { UGPT } from "types/react"; export default function useUGPTBase({ reqUser, game, playtype }: UGPT) { return useMemo( diff --git a/client/src/types/api-returns.ts b/client/src/types/api-returns.ts index 1cb169955..2979c1edc 100644 --- a/client/src/types/api-returns.ts +++ b/client/src/types/api-returns.ts @@ -218,3 +218,13 @@ export interface ImportsReturn { imports: Array; users: Array; } + +export interface ActivityReturn { + recentSessions: Array; + + songs: Array; + charts: Array; + recentlyHighlightedScores: Array; + + users: Array; +} diff --git a/client/src/types/tachi.ts b/client/src/types/tachi.ts index 046d57806..3a54324c4 100644 --- a/client/src/types/tachi.ts +++ b/client/src/types/tachi.ts @@ -1,3 +1,13 @@ -import { Game, Playtypes } from "tachi-common"; +import { ChartDocument, ScoreDocument, SessionDocument, SongDocument } from "tachi-common"; -export type Playtype = Playtypes[Game]; +export type ClumpedActivityScores = { + type: "SCORES"; + scores: Array; +}; + +export type ClumpedActivity = Array< + | ({ + type: "SESSION"; + } & SessionDocument) + | ClumpedActivityScores +>; diff --git a/client/src/util/activity.ts b/client/src/util/activity.ts new file mode 100644 index 000000000..be41546f0 --- /dev/null +++ b/client/src/util/activity.ts @@ -0,0 +1,70 @@ +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, + }); + } + + return clumped.sort( + NumericSOV( + (x) => (x.type === "SESSION" ? x.timeEnded : x.scores[0]!.timeAchieved ?? -Infinity), + 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..1a11bf74c --- /dev/null +++ b/server/src/lib/activity/activity.ts @@ -0,0 +1,67 @@ +import db from "external/mongo/db"; +import { ONE_MONTH } from "lib/constants/time"; +import { GetRelevantSongsAndCharts } from "utils/db"; +import { GetUsersWithIDs } from "utils/user"; +import type { Game, integer, Playtype } from "tachi-common"; + +/** + * Retrieves recent activity for this group of users for this GPT. + * + * At the moment, this retrieves the following events: + * - Recent Sessions + * - Recent Highlighted Scores + * + * It will support: + * - Recently achieved goals + * - Recently achieved quests + */ +export async function GetRecentActivity( + userIDs: Array, + game: Game, + playtype: Playtype, + timespan = ONE_MONTH +) { + // by default, limit to only things that happened in the past 31 days. + const RECENT = Date.now() - timespan; + + const recentlyHighlightedScores = await db.scores.find( + { + userID: { $in: userIDs }, + game, + playtype, + highlight: true, + timeAchieved: { $gte: RECENT }, + }, + { + sort: { + timeAchieved: -1, + }, + } + ); + + const { songs, charts } = await GetRelevantSongsAndCharts(recentlyHighlightedScores, game); + + const recentSessions = await db.sessions.find( + { + userID: { $in: userIDs }, + game, + playtype, + timeStarted: { $gte: RECENT }, + }, + { + sort: { + timeAchieved: -1, + }, + } + ); + + const users = await GetUsersWithIDs(userIDs); + + return { + recentSessions, + recentlyHighlightedScores, + songs, + charts, + users, + }; +} diff --git a/server/src/lib/rivals/rivals.ts b/server/src/lib/rivals/rivals.ts index 94b68a709..a2a53be9f 100644 --- a/server/src/lib/rivals/rivals.ts +++ b/server/src/lib/rivals/rivals.ts @@ -1,4 +1,5 @@ import db from "external/mongo/db"; +import { GetRecentActivity } from "lib/activity/activity"; import { SetRivalsFailReasons } from "lib/constants/err-codes"; import CreateLogCtx from "lib/logger/logger"; import { SendSetRivalNotification } from "lib/notifications/notification-wrappers"; @@ -6,7 +7,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, UserDocument } from "tachi-common"; const logger = CreateLogCtx(__filename); @@ -270,3 +271,14 @@ export async function UpdatePlayersRivalRankings(userID: integer, game: Game, pl await db["personal-bests"].bulkWrite(bwrite, { ordered: false }); } + +export async function GetRivalRecentActivity( + user: UserDocument, + game: Game, + playtype: Playtype, + timespan: number +) { + const rivalIDs = await GetRivalIDs(user.id, game, playtype); + + return GetRecentActivity(rivalIDs, game, playtype, timespan); +} diff --git a/server/src/server/router/api/v1/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..38f03e392 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 @@ -2,7 +2,14 @@ import { RequireAuthedAsUser } from "../../../../middleware"; import { Router } from "express"; import db from "external/mongo/db"; import { SetRivalsFailReasons } from "lib/constants/err-codes"; -import { GetChallengerUsers, GetRivalIDs, GetRivalUsers, SetRivals } from "lib/rivals/rivals"; +import { ONE_MONTH, ONE_YEAR } from "lib/constants/time"; +import { + GetChallengerUsers, + GetRivalIDs, + GetRivalRecentActivity, + GetRivalUsers, + SetRivals, +} from "lib/rivals/rivals"; import p from "prudence"; import { RequirePermissions } from "server/middleware/auth"; import prValidate from "server/middleware/prudence-validate"; @@ -167,4 +174,44 @@ router.get("/pb-leaderboard", async (req, res) => { }); }); +/** + * Retrieve activity for this user's ste of rivals. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/rivals/activity + */ +router.get( + "/activity", + prValidate({ duration: p.optional(p.isIn("month", "3month", "year")) }), + async (req, res) => { + const { user, game, playtype } = GetUGPT(req); + + const duration = (req.query.duration as "3month" | "month" | "year" | undefined) ?? "month"; + + let timespan; + + switch (duration) { + case "month": { + timespan = ONE_MONTH; + break; + } + + case "3month": { + timespan = ONE_MONTH * 3; + break; + } + + case "year": + timespan = ONE_YEAR; + } + + const recentActivity = await GetRivalRecentActivity(user, game, playtype, timespan); + + return res.status(200).json({ + success: true, + description: `Retrieved recent rival activity.`, + body: recentActivity, + }); + } +); + export default router; From a83920c62ac7e790ca644fe3ecb4f9d409e544d9 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Fri, 11 Nov 2022 02:50:42 +0000 Subject: [PATCH 3/7] feat: bump metronic-scss --- client/src/_assets/metronic-scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/_assets/metronic-scss b/client/src/_assets/metronic-scss index d44b4be29..66bb3bb1e 160000 --- a/client/src/_assets/metronic-scss +++ b/client/src/_assets/metronic-scss @@ -1 +1 @@ -Subproject commit d44b4be29500a7dfb668f6f5d9703c2f619d71f8 +Subproject commit 66bb3bb1e26426839cde0cd35dab46ff2e82e38f From e5cab18365ed25288f55a090e706fbb6c2994103 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Fri, 11 Nov 2022 03:34:14 +0000 Subject: [PATCH 4/7] feat: ugpt activity + activity improvements --- .../games/_game/_playtype/ActivityPage.tsx | 35 --- .../games/_game/_playtype/OverviewPage.tsx | 235 ++---------------- .../games/_game/_playtype/ScoresPage.tsx | 2 +- .../games/_game/_playtype/SessionsPage.tsx | 2 +- .../_playtype/folders/FoldersMainPage.tsx | 3 +- .../_playtype/rivals/RivalsActivityPage.tsx | 13 +- .../_game/_playtype/rivals/RivalsMainPage.tsx | 2 +- .../_game/_playtype/targets/TargetsPage.tsx | 2 +- client/src/components/user/UGPTActivity.tsx | 147 ++++++----- server/src/lib/activity/activity.ts | 40 +-- .../games/_game/_playtype/rivals/router.ts | 30 +-- .../_userID/games/_game/_playtype/router.ts | 31 +++ .../router/api/v1/users/_userID/router.ts | 2 + 13 files changed, 181 insertions(+), 363 deletions(-) delete mode 100644 client/src/app/pages/dashboard/users/games/_game/_playtype/ActivityPage.tsx diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/ActivityPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/ActivityPage.tsx deleted file mode 100644 index b5ab0702c..000000000 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/ActivityPage.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { ClumpActivity } from "util/activity"; -import ApiError from "components/util/ApiError"; -import Loading from "components/util/Loading"; -import useApiQuery from "components/util/query/useApiQuery"; -import React, { useState } from "react"; -import { ActivityReturn } from "types/api-returns"; -import { UGPT } from "types/react"; -import UGPTActivity from "components/user/UGPTActivity"; - -export default function ActivityPage({ reqUser, game, playtype }: UGPT) { - const [duration, setDuration] = useState<"month" | "3month" | "year">("month"); - - const { data, error } = useApiQuery( - `/users/${reqUser.id}/games/${game}/${playtype}/activity?duration=${duration}` - ); - - if (error) { - return ; - } - - if (!data) { - return ; - } - - const joined = ClumpActivity(data); - - return ( - - ); -} diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx index d36755104..bc3318d31 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx @@ -1,41 +1,25 @@ -import { APIFetchV1 } from "util/api"; -import { ONE_HOUR, ONE_MINUTE } from "util/constants/time"; -import { CreateChartMap, CreateSongMap, GetPBs } from "util/data"; +import { ClumpActivity } from "util/activity"; import { UppercaseFirst } from "util/misc"; -import { NumericSOV } from "util/sorts"; -import { FormatDate, FormatDuration, FormatTime, MillisToSince } from "util/time"; +import { FormatDate, MillisToSince } from "util/time"; import TimelineChart from "components/charts/TimelineChart"; import useSetSubheader from "components/layout/header/useSetSubheader"; import Card from "components/layout/page/Card"; -import MiniTable from "components/tables/components/MiniTable"; -import ScoreTable from "components/tables/scores/ScoreTable"; +import UGPTActivity from "components/user/UGPTActivity"; import UGPTStatShowcase from "components/user/UGPTStatShowcase"; -import AsyncLoader from "components/util/AsyncLoader"; +import ApiError from "components/util/ApiError"; import Divider from "components/util/Divider"; import Icon from "components/util/Icon"; +import Loading from "components/util/Loading"; import Muted from "components/util/Muted"; +import useApiQuery from "components/util/query/useApiQuery"; +import Select from "components/util/Select"; import SelectButton from "components/util/SelectButton"; import { useProfileRatingAlg } from "components/util/useScoreRatingAlg"; import { DateTime } from "luxon"; import React, { useMemo, useState } from "react"; -import { Badge, Row } from "react-bootstrap"; -import { - FormatGame, - GetGameConfig, - GetGamePTConfig, - UserDocument, - ScoreDocument, - SessionDocument, - UserGameStats, -} from "tachi-common"; -import { SessionReturns, UGPTHistory } from "types/api-returns"; +import { FormatGame, GetGameConfig, GetGamePTConfig, UserGameStats } from "tachi-common"; +import { ActivityReturn, UGPTHistory } from "types/api-returns"; import { GamePT, SetState, UGPT } from "types/react"; -import { ScoreDataset } from "types/tables"; -import SessionRaiseBreakdown from "components/sessions/SessionRaiseBreakdown"; -import useApiQuery from "components/util/query/useApiQuery"; -import ApiError from "components/util/ApiError"; -import Loading from "components/util/Loading"; -import Select from "components/util/Select"; export default function OverviewPage({ reqUser, game, playtype }: UGPT) { const gameConfig = GetGameConfig(game); @@ -49,202 +33,33 @@ export default function OverviewPage({ reqUser, game, playtype }: UGPT) { - + ); } -function LastSession({ reqUser, game, playtype }: UGPT) { - return ( - { - const res = await APIFetchV1( - `/users/${reqUser.id}/games/${game}/${playtype}/sessions/last` - ); - - if (res.statusCode === 404) { - return null; - } - - if (!res.success) { - throw new Error(res.description); - } - - const sessionDataRes = await APIFetchV1( - `/sessions/${res.body!.sessionID}` - ); - - if (!sessionDataRes.success) { - throw new Error(res.description); - } - - return { session: res.body, sessionData: sessionDataRes.body }; - }} - > - {(data) => - data && ( - -
-
-

{data.session.name}

- {data.session.desc} - -
- -
- - - Started - {FormatTime(data.session.timeStarted)} - - - {/* If session ended less than 2 hours ago, it can still be appended to. */} - {/* Kinda - time is a bit nonlinear wrt. importing scores, as scores don't have to have */} - {/* happened at the same time they were imported. */} - {/* that doesn't matter though - this is just a UI thing */} - - {Date.now() - data.session.timeEnded < ONE_HOUR * 2 - ? "Last Score" - : "Ended At"} - - - {FormatTime(data.session.timeEnded)} - {/* if the last score was less than 10 mins ago, this session is probably still being populated */} - {Date.now() - data.session.timeEnded < - ONE_MINUTE * 10 && ( - <> -
- - Ongoing! - - - )} - - - - Duration - - {FormatDuration( - data.session.timeEnded - data.session.timeStarted - )} - - - - Scores - {data.session.scoreInfo.length} - - - PBs - {GetPBs(data.session.scoreInfo).length} - -
-
-
- - void 0} - /> - -
-
- - -
-
-
- ) - } -
- ); -} - -function RecentSessionScoreInfo({ - session, - sessionData, - reqUser, -}: { - session: SessionDocument; - sessionData: SessionReturns; - reqUser: UserDocument; -}) { - const { game, playtype } = session; - - const highlightedScores = sessionData.scores.filter((e) => e.highlight); - - const [mode, setMode] = useState<"highlight" | "best" | "recent">( - highlightedScores.length > 0 ? "highlight" : "best" +function RecentActivity({ reqUser, game, playtype }: UGPT) { + const { data, error } = useApiQuery( + `/users/${reqUser.id}/games/${game}/${playtype}/activity` ); - const gptConfig = GetGamePTConfig(game, playtype); + if (error) { + return ; + } - const songMap = CreateSongMap(sessionData.songs); - const chartMap = CreateChartMap(sessionData.charts); + if (!data) { + return ; + } - const dataset = useMemo(() => { - let scoreSet = sessionData.scores; - if (mode === "highlight") { - scoreSet = highlightedScores; - } else if (mode === "best") { - scoreSet.sort((a, b) => - NumericSOV( - (d) => d.calculatedData[gptConfig.defaultScoreRatingAlg] ?? 0 - )(b, a) - ); - } else if (mode === "recent") { - // sneaky hack to sort in reverse - scoreSet.sort((a, b) => NumericSOV((d) => d.timeAchieved ?? 0)(b, a)); - } + const joined = ClumpActivity(data); - const scoreDataset: ScoreDataset = []; - - for (const score of scoreSet) { - const chart = chartMap.get(score.chartID)!; - - scoreDataset.push({ - ...score, - __related: { - chart, - song: songMap.get(chart.songID)!, - index: 0, - user: reqUser, - }, - }); - } - - return scoreDataset; - }, [mode]); + if (joined.length === 0) { + return null; + } return ( -
-
-
- - - Highlights - - - - Best - - - - Recent - -
-
-
- -
+
+
); } diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx index e20aff3de..4085ad0f9 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/ScoresPage.tsx @@ -54,7 +54,7 @@ export default function ScoresPage({ return (
-
+
Best 100 PBs diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/SessionsPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/SessionsPage.tsx index 2de286230..dd6f5b686 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/SessionsPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/SessionsPage.tsx @@ -69,7 +69,7 @@ export default function SessionsPage({ return (
-
+
Best Sessions 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 e9f7318fb..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 @@ -31,7 +31,7 @@ export default function FoldersMainPage({ reqUser, game, playtype }: UGPT) { return (
-
+
{user && ugs?.find((x) => x.game === game && x.playtype === playtype) && ( @@ -50,7 +50,6 @@ export default function FoldersMainPage({ reqUser, game, playtype }: UGPT) { Folder Select
-
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 index 3199a17bd..576ea64cc 100644 --- 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 @@ -8,10 +8,8 @@ import { UGPT } from "types/react"; import UGPTActivity from "components/user/UGPTActivity"; export default function RivalsActivityPage({ reqUser, game, playtype }: UGPT) { - const [duration, setDuration] = useState<"month" | "3month" | "year">("month"); - const { data, error } = useApiQuery( - `/users/${reqUser.id}/games/${game}/${playtype}/rivals/activity?duration=${duration}` + `/users/${reqUser.id}/games/${game}/${playtype}/rivals/activity` ); if (error) { @@ -24,12 +22,5 @@ export default function RivalsActivityPage({ reqUser, game, playtype }: UGPT) { const joined = ClumpActivity(data); - return ( - - ); + 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 94c479e55..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 @@ -40,7 +40,7 @@ export default function RivalsMainPage({ return ( -
+
Rival's Bests 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 aa01bf178..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 @@ -24,7 +24,7 @@ export default function TargetsPage({ reqUser, game, playtype }: UGPT) { return ( -
+
Goals diff --git a/client/src/components/user/UGPTActivity.tsx b/client/src/components/user/UGPTActivity.tsx index ff7fb2c24..2f392209f 100644 --- a/client/src/components/user/UGPTActivity.tsx +++ b/client/src/components/user/UGPTActivity.tsx @@ -1,89 +1,85 @@ import { CreateUserMap } from "util/data"; +import { NO_OP, TruncateString } from "util/misc"; import { FormatTime, MillisToSince } from "util/time"; -import { NO_OP } from "util/misc"; +import { ONE_HOUR } from "util/constants/time"; +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, { useState } from "react"; import { Col, Row } from "react-bootstrap"; import { FormatChart, SessionDocument, UserDocument } from "tachi-common"; -import ScoreTable from "components/tables/scores/ScoreTable"; -import { ScoreDataset } from "types/tables"; -import Divider from "components/util/Divider"; -import SessionRaiseBreakdown from "components/sessions/SessionRaiseBreakdown"; -import useApiQuery from "components/util/query/useApiQuery"; -import ApiError from "components/util/ApiError"; -import Loading from "components/util/Loading"; import { SessionReturns } from "types/api-returns"; -import LinkButton from "components/util/LinkButton"; +import { ScoreDataset } from "types/tables"; import { ClumpedActivity, ClumpedActivityScores } from "types/tachi"; -import { SetState } from "types/react"; -import Select from "components/util/Select"; -// Records activity for a group of users on a GPT. Also used for single users, sometimes.P +// Records activity for a group of users on a GPT. Also used for single users. export default function UGPTActivity({ data, users, - duration, - setDuration, }: { data: ClumpedActivity; users: Array; - duration: "month" | "3month" | "year"; - setDuration: SetState<"month" | "3month" | "year">; }) { - const rivalMap = CreateUserMap(users); + const userMap = CreateUserMap(users); + + if (data.length === 0) { + return ( + + We found no activity! + + ); + } return ( - <> - - -
-
- {data.map((e) => { - const rival = rivalMap.get( - e.type === "SCORES" ? e.scores[0]?.userID : e.userID - ); + +
+
+ {data.map((e) => { + const user = userMap.get(e.type === "SCORES" ? e.scores[0]?.userID : e.userID); - if (!rival) { - return
This user doesn't exist? Whoops.
; - } + if (!user) { + return
This user doesn't exist? Whoops.
; + } - return e.type === "SCORES" ? ( - - ) : ( - - ); - })} + return e.type === "SCORES" ? ( + + ) : ( + + ); + })} +
-
-
-
- This is the end of the recent activity! -
+
+
+ ...
- - +
+ ); } -function ScoresActivity({ data, rival }: { data: ClumpedActivityScores; rival: UserDocument }) { +function ScoresActivity({ data, user: 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]; @@ -93,8 +89,19 @@ function ScoresActivity({ data, rival }: { data: ClumpedActivityScores; rival: U 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) => ({ @@ -102,7 +109,7 @@ function ScoresActivity({ data, rival }: { data: ClumpedActivityScores; rival: U __related: { ...e.__related, index: i, - user: rival, + user, }, })); @@ -129,8 +136,14 @@ function ScoresActivity({ data, rival }: { data: ClumpedActivityScores; rival: U }} /> - {rival.username} highlighted {subMessage}! + {user.username} highlighted {subMessage}! + {mutedText && ( + <> +
+ {mutedText} + + )}
@@ -160,13 +173,15 @@ function ScoresActivity({ data, rival }: { data: ClumpedActivityScores; rival: U function SessionActivity({ data, - rival, + user: rival, }: { data: { type: "SESSION" } & SessionDocument; - rival: UserDocument; + user: UserDocument; }) { const [show, setShow] = useState(false); + const isProbablyActive = Date.now() - data.timeEnded < ONE_HOUR; + return (
@@ -189,13 +204,21 @@ function SessionActivity({ fontSize: "0.75rem", }} /> - - {rival.username} had a session '{data.name}' with{" "} - {data.scoreInfo.length}{" "} + + {rival.username} {isProbablyActive ? "is having" : "had"} a session ' + {data.name}' with {data.scoreInfo.length}{" "} {data.scoreInfo.length === 1 ? "score" : "scores"}.
- {data.desc && {data.desc}} + {data.desc && data.desc !== "This session has no description." && ( + {data.desc} + )}
diff --git a/server/src/lib/activity/activity.ts b/server/src/lib/activity/activity.ts index 1a11bf74c..da32a967b 100644 --- a/server/src/lib/activity/activity.ts +++ b/server/src/lib/activity/activity.ts @@ -1,5 +1,4 @@ import db from "external/mongo/db"; -import { ONE_MONTH } from "lib/constants/time"; import { GetRelevantSongsAndCharts } from "utils/db"; import { GetUsersWithIDs } from "utils/user"; import type { Game, integer, Playtype } from "tachi-common"; @@ -19,10 +18,27 @@ export async function GetRecentActivity( userIDs: Array, game: Game, playtype: Playtype, - timespan = ONE_MONTH + sessions = 30 ) { - // by default, limit to only things that happened in the past 31 days. - const RECENT = Date.now() - timespan; + const recentSessions = await db.sessions.find( + { + userID: { $in: userIDs }, + game, + playtype, + }, + { + 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 recentlyHighlightedScores = await db.scores.find( { @@ -30,7 +46,7 @@ export async function GetRecentActivity( game, playtype, highlight: true, - timeAchieved: { $gte: RECENT }, + timeAchieved: { $gte: earliestSession }, }, { sort: { @@ -41,20 +57,6 @@ export async function GetRecentActivity( const { songs, charts } = await GetRelevantSongsAndCharts(recentlyHighlightedScores, game); - const recentSessions = await db.sessions.find( - { - userID: { $in: userIDs }, - game, - playtype, - timeStarted: { $gte: RECENT }, - }, - { - sort: { - timeAchieved: -1, - }, - } - ); - const users = await GetUsersWithIDs(userIDs); return { 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 38f03e392..b90fcdf38 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 @@ -175,36 +175,26 @@ router.get("/pb-leaderboard", async (req, res) => { }); /** - * Retrieve activity for this user's ste of rivals. + * Retrieve activity for this user's set of rivals. + * + * @param sessions - How many sessions' worth of activity do we want? + * This is a more reliable way of fetching *atleast some* activity than say, + * time. * * @name GET /api/v1/users/:userID/games/:game/:playtype/rivals/activity */ router.get( "/activity", - prValidate({ duration: p.optional(p.isIn("month", "3month", "year")) }), + prValidate({ sessions: p.optional(p.isIn("month", "3month", "year")) }), async (req, res) => { const { user, game, playtype } = GetUGPT(req); - const duration = (req.query.duration as "3month" | "month" | "year" | undefined) ?? "month"; + const qSessions = req.query.sessions as string | undefined; - let timespan; + // defaulting to 30 seems sensible. + const sessions = qSessions ? Number(qSessions) : 30; - switch (duration) { - case "month": { - timespan = ONE_MONTH; - break; - } - - case "3month": { - timespan = ONE_MONTH * 3; - break; - } - - case "year": - timespan = ONE_YEAR; - } - - const recentActivity = await GetRivalRecentActivity(user, game, playtype, timespan); + const recentActivity = await GetRivalRecentActivity(user, game, playtype, sessions); return res.status(200).json({ success: true, 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..5a35730cc 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 { GetRecentActivity } 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,36 @@ router.get("/leaderboard-adjacent", async (req, res) => { }); }); +/** + * Retrieve activity for this user. + * + * @param sessions - How many sessions' worth of activity do we want? + * This is a more reliable way of fetching *atleast some* activity than say, + * time. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/rivals/activity + */ +router.get( + "/activity", + prValidate({ sessions: p.optional((s) => p.isBoundedInteger(10, 100)(Number(s))) }), + async (req, res) => { + const { user, game, playtype } = GetUGPT(req); + + const qSessions = req.query.sessions as string | undefined; + + // defaulting to 30 seems sensible. + const sessions = qSessions ? Number(qSessions) : 30; + + const recentActivity = await GetRecentActivity([user.id], game, playtype, sessions); + + return res.status(200).json({ + success: true, + description: `Retrieved recent rival activity.`, + body: recentActivity, + }); + } +); + 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); From ac7486592b67fab47ca4950e3cceab6cef0760ea Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Fri, 11 Nov 2022 04:51:39 +0000 Subject: [PATCH 5/7] feat: full ugpt activity stuff --- .../games/_game/_playtype/GPTMainPage.tsx | 372 +----------------- .../games/_game/_playtype/OverviewPage.tsx | 27 +- .../_playtype/rivals/RivalsActivityPage.tsx | 25 +- .../Activity.tsx} | 208 ++++++++-- client/src/components/game/GPTHeader.tsx | 4 +- client/src/types/api-returns.ts | 1 + client/src/types/tachi.ts | 21 +- client/src/util/activity.ts | 21 +- server/src/lib/activity/activity.ts | 137 ++++++- server/src/lib/rivals/rivals.ts | 14 +- .../api/v1/games/_game/_playtype/router.ts | 106 +---- .../v1/games/_game/_playtype/scores/router.ts | 58 --- .../games/_game/_playtype/rivals/router.ts | 42 +- .../_userID/games/_game/_playtype/router.ts | 36 +- 14 files changed, 386 insertions(+), 686 deletions(-) rename client/src/components/{user/UGPTActivity.tsx => activity/Activity.tsx} (57%) delete mode 100644 server/src/server/router/api/v1/games/_game/_playtype/scores/router.ts diff --git a/client/src/app/pages/dashboard/games/_game/_playtype/GPTMainPage.tsx b/client/src/app/pages/dashboard/games/_game/_playtype/GPTMainPage.tsx index b5bef3b97..78d6c1c77 100644 --- a/client/src/app/pages/dashboard/games/_game/_playtype/GPTMainPage.tsx +++ b/client/src/app/pages/dashboard/games/_game/_playtype/GPTMainPage.tsx @@ -1,43 +1,8 @@ -import { APIFetchV1 } from "util/api"; -import { DEFAULT_BAR_PROPS } from "util/charts"; -import { CreateChartMap, CreateGoalMap, CreateSongMap, CreateUserMap } from "util/data"; -import { UppercaseFirst } from "util/misc"; -import { MillisToSince } from "util/time"; -import { ResponsiveBar } from "@nivo/bar"; -import { BarChartTooltip } from "components/charts/ChartTooltip"; -import ClassBadge from "components/game/ClassBadge"; +import Activity from "components/activity/Activity"; import useSetSubheader from "components/layout/header/useSetSubheader"; -import Card from "components/layout/page/Card"; -import GoalSubTable from "components/tables/goals/GoalSubTable"; -import ScoreTable from "components/tables/scores/ScoreTable"; -import ApiError from "components/util/ApiError"; -import AsyncLoader from "components/util/AsyncLoader"; -import Divider from "components/util/Divider"; -import Icon from "components/util/Icon"; -import Loading from "components/util/Loading"; -import useApiQuery from "components/util/query/useApiQuery"; -import { ColourConfig, TachiConfig } from "lib/config"; -import React, { useEffect, useState } from "react"; -import { Col, Form, Row } from "react-bootstrap"; -import { Link } from "react-router-dom"; -import { - ChartDocument, - ClassAchievementDocument, - FormatGame, - gameClasses, - GamePTConfig, - GetGameConfig, - GetGamePTConfig, - GoalDocument, - integer, - UserDocument, - ScoreDocument, - SongDocument, - GoalSubscriptionDocument, -} from "tachi-common"; -import { RecentClassesReturn } from "types/api-returns"; +import React from "react"; +import { FormatGame, GetGameConfig } from "tachi-common"; import { GamePT } from "types/react"; -import { ScoreDataset } from "types/tables"; export default function GPTMainPage({ game, playtype }: GamePT) { useSetSubheader( @@ -46,334 +11,5 @@ export default function GPTMainPage({ game, playtype }: GamePT) { FormatGame(game, playtype) ); - const gptConfig = GetGamePTConfig(game, playtype); - - return ( - <> - - - - - - - - {Object.keys(gptConfig.classHumanisedFormat).length !== 0 && ( - <> - - - - - - - - - - )} - - ); -} - -function RecentAchievedGoalsComponent({ game, playtype }: GamePT) { - const { data, error } = useApiQuery<{ - users: UserDocument[]; - goals: GoalDocument[]; - goalSubs: GoalSubscriptionDocument[]; - }>(`/games/${game}/${playtype}/goals/recently-achieved`); - - if (error) { - return ; - } - - if (!data) { - return ; - } - - const dataset = []; - - const userMap = CreateUserMap(data.users); - const goalMap = CreateGoalMap(data.goals); - - for (const userGoal of data.goalSubs) { - const user = userMap.get(userGoal.userID); - const goal = goalMap.get(userGoal.goalID); - - if (!user || !goal) { - console.warn( - `Couldn't find goal or user for ${userGoal.userID}, ${userGoal.goalID}? Skipping.` - ); - continue; - } - - dataset.push({ - ...userGoal, - __related: { - user, - goal, - }, - }); - } - - return ( - - - Here's what goals players have recently achieved! - - - - - - - - - ); -} - -function RecentHighlightedScoresComponent({ game, playtype }: GamePT) { - const { data, error } = useApiQuery<{ - scores: ScoreDocument[]; - users: UserDocument[]; - charts: ChartDocument[]; - songs: SongDocument[]; - }>(`/games/${game}/${playtype}/scores/highlighted`); - - if (error) { - return ; - } - - if (!data) { - return ; - } - - const dataset: ScoreDataset = []; - - const songMap = CreateSongMap(data.songs); - const chartMap = CreateChartMap(data.charts); - const userMap = CreateUserMap(data.users); - - for (const [index, score] of data.scores.entries()) { - dataset.push({ - ...score, - __related: { - chart: chartMap.get(score.chartID)!, - song: songMap.get(score.songID)!, - index, - user: userMap.get(score.userID)!, - }, - }); - } - - return ( - - - Here's what players have recently marked as highlighted scores! - - - - - - - - - ); -} - -function RecentClassesComponent({ game, playtype }: GamePT) { - const { data, error } = useApiQuery( - `/games/${game}/${playtype}/recent-classes` - ); - - if (error) { - return ; - } - - if (!data) { - return ; - } - - // if (data.classes.length === 0) { - // return
Looks like nobodies got anything here... yet!
; - // } - - const userMap = new Map(); - - for (const user of data.users) { - userMap.set(user.id, user); - } - - return ( -
- {data.classes.length === 0 ? ( - Looks like nobodies ever got a class for this game. Ah well. - ) : ( - data.classes.map((c, i) => { - const user = userMap.get(c.userID)!; - - return ; - }) - )} -
- ); -} - -function AchievedClassRow({ - classInfo, - user, -}: { - classInfo: ClassAchievementDocument; - user: UserDocument; -}) { - const game = classInfo.game; - const playtype = classInfo.playtype; - - return ( -
- - {user.username} - {" "} - achieved{" "} - - {classInfo.classOldValue !== null && ( - <> - {" "} - (Raised from{" "} - - ) - - )} - - - - {MillisToSince(classInfo.timeAchieved)} - -
- ); -} - -function ClassDistributionComponent({ game, playtype }: GamePT) { - const gptConfig = GetGamePTConfig(game, playtype); - - const [className, setClassName] = useState( - Object.keys(gptConfig.classHumanisedFormat)[0] as gameClasses.AllClassSets - ); - - useEffect(() => { - const gptConfig = GetGamePTConfig(game, playtype); - setClassName(Object.keys(gptConfig.classHumanisedFormat)[0] as gameClasses.AllClassSets); - }, [game, playtype]); - - return ( - - - This will show the distribution of classes among all the players on{" "} - {TachiConfig.name}! - - - - - - setClassName(e.target.value as gameClasses.AllClassSets)} - > - {Object.keys(gptConfig.classHumanisedFormat).map((e) => ( - - ))} - - - - - - - { - const res = await APIFetchV1>( - `/games/${game}/${playtype}/class-distribution?class=${className}` - ); - - if (!res.success) { - throw new Error(res.description); - } - - return res.body; - }} - > - {(d) => } - - - - ); -} - -function ClassDistChart({ - data, - gptConfig, - className, -}: { - data: Record; - gptConfig: GamePTConfig; - className: gameClasses.AllClassSets; -}) { - const dataset = []; - - for (const key in gptConfig.classHumanisedFormat[className]) { - dataset.push({ - id: key, - count: data[key] ?? 0, - }); - } - - if (dataset.length === 0) { - return ( -
-
- Looks like nobody has any of these classes yet. -
-
- ); - } - - return ( -
- ColourConfig.primary} - axisLeft={{ - format: (v) => gptConfig.classHumanisedFormat[className][v]?.display, - }} - tooltip={(d) => ( - ( -
- { - gptConfig.classHumanisedFormat[className][ - d.indexValue as number - ].display - } - : {(d.data as any).count} -
- )} - /> - )} - {...DEFAULT_BAR_PROPS} - /> -
- ); + return ; } diff --git a/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx b/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx index bc3318d31..d1580f416 100644 --- a/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx +++ b/client/src/app/pages/dashboard/users/games/_game/_playtype/OverviewPage.tsx @@ -4,7 +4,7 @@ import { FormatDate, MillisToSince } from "util/time"; import TimelineChart from "components/charts/TimelineChart"; import useSetSubheader from "components/layout/header/useSetSubheader"; import Card from "components/layout/page/Card"; -import UGPTActivity from "components/user/UGPTActivity"; +import Activity from "components/activity/Activity"; import UGPTStatShowcase from "components/user/UGPTStatShowcase"; import ApiError from "components/util/ApiError"; import Divider from "components/util/Divider"; @@ -18,7 +18,7 @@ import { useProfileRatingAlg } from "components/util/useScoreRatingAlg"; import { DateTime } from "luxon"; import React, { useMemo, useState } from "react"; import { FormatGame, GetGameConfig, GetGamePTConfig, UserGameStats } from "tachi-common"; -import { ActivityReturn, UGPTHistory } from "types/api-returns"; +import { UGPTHistory } from "types/api-returns"; import { GamePT, SetState, UGPT } from "types/react"; export default function OverviewPage({ reqUser, game, playtype }: UGPT) { @@ -39,27 +39,12 @@ export default function OverviewPage({ reqUser, game, playtype }: UGPT) { } function RecentActivity({ reqUser, game, playtype }: UGPT) { - const { data, error } = useApiQuery( - `/users/${reqUser.id}/games/${game}/${playtype}/activity` - ); - - if (error) { - return ; - } - - if (!data) { - return ; - } - - const joined = ClumpActivity(data); - - if (joined.length === 0) { - return null; - } - return (
- +
); } 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 index 576ea64cc..ddb513e0c 100644 --- 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 @@ -1,26 +1,7 @@ -import { ClumpActivity } from "util/activity"; -import ApiError from "components/util/ApiError"; -import Loading from "components/util/Loading"; -import useApiQuery from "components/util/query/useApiQuery"; -import React, { useState } from "react"; -import { ActivityReturn } from "types/api-returns"; +import Activity from "components/activity/Activity"; +import React from "react"; import { UGPT } from "types/react"; -import UGPTActivity from "components/user/UGPTActivity"; export default function RivalsActivityPage({ reqUser, game, playtype }: UGPT) { - const { data, error } = useApiQuery( - `/users/${reqUser.id}/games/${game}/${playtype}/rivals/activity` - ); - - if (error) { - return ; - } - - if (!data) { - return ; - } - - const joined = ClumpActivity(data); - - return ; + return ; } diff --git a/client/src/components/user/UGPTActivity.tsx b/client/src/components/activity/Activity.tsx similarity index 57% rename from client/src/components/user/UGPTActivity.tsx rename to client/src/components/activity/Activity.tsx index 2f392209f..ed8284ba3 100644 --- a/client/src/components/user/UGPTActivity.tsx +++ b/client/src/components/activity/Activity.tsx @@ -2,6 +2,8 @@ import { CreateUserMap } from "util/data"; import { NO_OP, TruncateString } from "util/misc"; import { FormatTime, MillisToSince } from "util/time"; import { ONE_HOUR } from "util/constants/time"; +import { ClumpActivity } from "util/activity"; +import { APIFetchV1 } from "util/api"; import SessionRaiseBreakdown from "components/sessions/SessionRaiseBreakdown"; import ScoreTable from "components/tables/scores/ScoreTable"; import ApiError from "components/util/ApiError"; @@ -11,34 +13,90 @@ import LinkButton from "components/util/LinkButton"; import Loading from "components/util/Loading"; import Muted from "components/util/Muted"; import useApiQuery from "components/util/query/useApiQuery"; -import React, { useState } from "react"; -import { Col, Row } from "react-bootstrap"; -import { FormatChart, SessionDocument, UserDocument } from "tachi-common"; -import { SessionReturns } from "types/api-returns"; +import React, { useEffect, useState } from "react"; +import { Button, Col, Row } from "react-bootstrap"; +import { FormatChart, UserDocument } from "tachi-common"; +import { ActivityReturn, SessionReturns } from "types/api-returns"; import { ScoreDataset } from "types/tables"; -import { ClumpedActivity, ClumpedActivityScores } from "types/tachi"; +import { + ClumpedActivity, + ClumpedActivityClassAchievement, + ClumpedActivityScores, + ClumpedActivitySession, +} from "types/tachi"; +import ClassBadge from "components/game/ClassBadge"; +import { Link } from "react-router-dom"; // Records activity for a group of users on a GPT. Also used for single users. -export default function UGPTActivity({ - data, - users, +export default function Activity({ + url, + handleNoActivity = ( + + We found no activity! + + ), }: { - data: ClumpedActivity; - users: Array; + url: string; + handleNoActivity?: React.ReactNode; }) { - const userMap = CreateUserMap(users); + const [clumped, setClumped] = useState([]); + const [users, setUsers] = useState>([]); - if (data.length === 0) { - return ( - - We found no activity! - - ); + 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); @@ -47,11 +105,14 @@ export default function UGPTActivity({ return
This user doesn't exist? Whoops.
; } - return e.type === "SCORES" ? ( - - ) : ( - - ); + switch (e.type) { + case "SCORES": + return ; + case "SESSION": + return ; + case "CLASS_ACHIEVEMENT": + return ; + } })}
@@ -64,7 +125,33 @@ export default function UGPTActivity({ marginRight: "2rem", }} > - ... +
@@ -73,7 +160,7 @@ export default function UGPTActivity({ ); } -function ScoresActivity({ data, user: user }: { data: ClumpedActivityScores; user: UserDocument }) { +function ScoresActivity({ data, user }: { data: ClumpedActivityScores; user: UserDocument }) { const { game, playtype } = data.scores[0]; const [show, setShow] = useState(false); @@ -171,13 +258,7 @@ function ScoresActivity({ data, user: user }: { data: ClumpedActivityScores; use ); } -function SessionActivity({ - data, - user: rival, -}: { - data: { type: "SESSION" } & SessionDocument; - user: UserDocument; -}) { +function SessionActivity({ data, user }: { data: ClumpedActivitySession; user: UserDocument }) { const [show, setShow] = useState(false); const isProbablyActive = Date.now() - data.timeEnded < ONE_HOUR; @@ -211,7 +292,7 @@ function SessionActivity({ fontSize: isProbablyActive ? "1.2rem" : undefined, }} > - {rival.username} {isProbablyActive ? "is having" : "had"} a session ' + {user.username} {isProbablyActive ? "is having" : "had"} a session ' {data.name}' with {data.scoreInfo.length}{" "} {data.scoreInfo.length === 1 ? "score" : "scores"}. @@ -263,3 +344,64 @@ function SessionShower({ sessionID }: { sessionID: string }) { ); } + +function ClassAchievementActivity({ + data, + user, +}: { + data: ClumpedActivityClassAchievement; + user: UserDocument; +}) { + return ( +
+
+
+
+
+ + {user.username} + {" "} + achieved{" "} + + {data.classOldValue !== null && ( + <> + {" "} + (Raised from{" "} + + ) + + )} +
+ +
+ {MillisToSince(data.timeAchieved)} +
+ + {FormatTime(data.timeAchieved)} + +
+
+
+
+ ); +} diff --git a/client/src/components/game/GPTHeader.tsx b/client/src/components/game/GPTHeader.tsx index eaf1fdd85..b90aec9da 100644 --- a/client/src/components/game/GPTHeader.tsx +++ b/client/src/components/game/GPTHeader.tsx @@ -7,8 +7,8 @@ export function GPTBottomNav({ baseUrl }: { baseUrl: string }) { const { settings } = useContext(UserSettingsContext); const navItems = [ - - Overview + + Activity , Songs diff --git a/client/src/types/api-returns.ts b/client/src/types/api-returns.ts index 2979c1edc..54aea8d95 100644 --- a/client/src/types/api-returns.ts +++ b/client/src/types/api-returns.ts @@ -225,6 +225,7 @@ export interface ActivityReturn { songs: Array; charts: Array; recentlyHighlightedScores: Array; + achievedClasses: Array; users: Array; } diff --git a/client/src/types/tachi.ts b/client/src/types/tachi.ts index 3a54324c4..06f70f102 100644 --- a/client/src/types/tachi.ts +++ b/client/src/types/tachi.ts @@ -1,13 +1,24 @@ -import { ChartDocument, ScoreDocument, SessionDocument, SongDocument } from "tachi-common"; +import { + ChartDocument, + ClassAchievementDocument, + ScoreDocument, + SessionDocument, + SongDocument, +} from "tachi-common"; export type ClumpedActivityScores = { type: "SCORES"; scores: Array; }; +export type ClumpedActivitySession = { + type: "SESSION"; +} & SessionDocument; + +export type ClumpedActivityClassAchievement = { + type: "CLASS_ACHIEVEMENT"; +} & ClassAchievementDocument; + export type ClumpedActivity = Array< - | ({ - type: "SESSION"; - } & SessionDocument) - | ClumpedActivityScores + ClumpedActivitySession | ClumpedActivityScores | ClumpedActivityClassAchievement >; diff --git a/client/src/util/activity.ts b/client/src/util/activity.ts index be41546f0..7431be54b 100644 --- a/client/src/util/activity.ts +++ b/client/src/util/activity.ts @@ -61,10 +61,23 @@ export function ClumpActivity(data: ActivityReturn): ClumpedActivity { }); } + for (const ach of data.achievedClasses) { + clumped.push({ + type: "CLASS_ACHIEVEMENT", + ...ach, + }); + } + return clumped.sort( - NumericSOV( - (x) => (x.type === "SESSION" ? x.timeEnded : x.scores[0]!.timeAchieved ?? -Infinity), - true - ) + NumericSOV((x) => { + switch (x.type) { + case "SESSION": + return x.timeStarted; + case "SCORES": + return x.scores[0]?.timeAchieved ?? -Infinity; + case "CLASS_ACHIEVEMENT": + return x.timeAchieved; + } + }, true) ); } diff --git a/server/src/lib/activity/activity.ts b/server/src/lib/activity/activity.ts index da32a967b..54112d2f7 100644 --- a/server/src/lib/activity/activity.ts +++ b/server/src/lib/activity/activity.ts @@ -1,7 +1,13 @@ import db from "external/mongo/db"; import { GetRelevantSongsAndCharts } from "utils/db"; +import { DedupeArr } from "utils/misc"; +import { GetGPT } from "utils/req-tachi-data"; import { GetUsersWithIDs } from "utils/user"; -import type { Game, integer, Playtype } from "tachi-common"; +import type { Request, Response } from "express-serve-static-core"; +import type { FilterQuery } from "mongodb"; +import type { ClassAchievementDocument, Game, ScoreDocument, SessionDocument } from "tachi-common"; + +type ActivityConstraint = FilterQuery; /** * Retrieves recent activity for this group of users for this GPT. @@ -9,30 +15,41 @@ import type { Game, integer, Playtype } from "tachi-common"; * At the moment, this retrieves the following events: * - Recent Sessions * - Recent Highlighted Scores + * - Achieved Classes * * It will support: * - Recently achieved goals * - Recently achieved quests + * + * To get the set of things we want to fetch, we fetch the first N sessions we see. + * This sets our "upper bound" for how far back we want to look -- when the Nth session + * started. + * + * Optionally, startFrom can be passed, which will start this activity search from that + * point in time. */ export async function GetRecentActivity( - userIDs: Array, + // todo: is it possible to make this game agnostic? game: Game, - playtype: Playtype, - sessions = 30 + query: ActivityConstraint, + sessions = 30, + startFrom: number | null = null ) { - const recentSessions = await db.sessions.find( - { - userID: { $in: userIDs }, - game, - playtype, + let baseQuery = query; + + if (startFrom !== null) { + baseQuery = { + ...baseQuery, + timeStarted: { $lt: startFrom }, + }; + } + + const recentSessions = await db.sessions.find(baseQuery, { + sort: { + timeStarted: -1, }, - { - sort: { - timeStarted: -1, - }, - limit: sessions, - } - ); + limit: sessions, + }); // find the earliest point in the sessions we just fetched. // if we found no sessions, set this to now, which means we'll fetch no highlighted @@ -40,13 +57,30 @@ export async function GetRecentActivity( // (it's not possible to have no sessions *and* have scores with timestamps) const earliestSession = recentSessions.at(-1)?.timeStarted ?? Date.now(); + const timeConstraint = + startFrom !== null + ? { $lt: startFrom, $gte: earliestSession } + : { + $gte: earliestSession, + }; + + const achievedClasses = await db["class-achievements"].find( + { + ...baseQuery, + timeAchieved: timeConstraint, + }, + { + sort: { + timeAchieved: -1, + }, + } + ); + const recentlyHighlightedScores = await db.scores.find( { - userID: { $in: userIDs }, - game, - playtype, + ...baseQuery, highlight: true, - timeAchieved: { $gte: earliestSession }, + timeAchieved: timeConstraint, }, { sort: { @@ -57,6 +91,12 @@ export async function GetRecentActivity( const { songs, charts } = await GetRelevantSongsAndCharts(recentlyHighlightedScores, game); + const userIDs = DedupeArr([ + ...recentSessions.map((e) => e.userID), + ...recentlyHighlightedScores.map((e) => e.userID), + ...achievedClasses.map((e) => e.userID), + ]); + const users = await GetUsersWithIDs(userIDs); return { @@ -64,6 +104,63 @@ export async function GetRecentActivity( recentlyHighlightedScores, songs, charts, + achievedClasses, users, }; } + +/** + * Utility for creating an express handler for activity-related endpoints. These endpoints + * are all *remarkably* similar, but with slightly different initial constraints. + * + * This creates a function that you should call inside another route. + */ +export function CreateActivityRouteHandler(query: ActivityConstraint) { + return async (req: Request, res: Response) => { + const { game } = GetGPT(req); + + const qSessions = req.query.sessions; + const qStartTime = req.query.startTime; + + if (qSessions !== undefined && typeof qSessions !== "string") { + return res.status(400).json({ + success: false, + description: `Invalid 'sessions'.`, + }); + } + + if (qStartTime !== undefined && typeof qStartTime !== "string") { + return res.status(400).json({ + success: false, + description: `Invalid 'startTime'.`, + }); + } + + // defaulting to 30 seems sensible. + const sessions = qSessions ? Number(qSessions) : 30; + + if (sessions > 100 || sessions < 10 || Number.isNaN(sessions)) { + return res.status(400).json({ + success: false, + description: `Invalid sessions, got ${sessions}, which wasn't between 10 and 100.`, + }); + } + + const startTime = qStartTime ? Number(qStartTime) : null; + + if (Number.isNaN(startTime)) { + return res.status(400).json({ + success: false, + description: `Invalid startTime, got a non number.`, + }); + } + + const recentActivity = await GetRecentActivity(game, query, sessions, startTime); + + return res.status(200).json({ + success: true, + description: `Retrieved activity.`, + body: recentActivity, + }); + }; +} diff --git a/server/src/lib/rivals/rivals.ts b/server/src/lib/rivals/rivals.ts index a2a53be9f..5a583732e 100644 --- a/server/src/lib/rivals/rivals.ts +++ b/server/src/lib/rivals/rivals.ts @@ -1,5 +1,4 @@ import db from "external/mongo/db"; -import { GetRecentActivity } from "lib/activity/activity"; import { SetRivalsFailReasons } from "lib/constants/err-codes"; import CreateLogCtx from "lib/logger/logger"; import { SendSetRivalNotification } from "lib/notifications/notification-wrappers"; @@ -7,7 +6,7 @@ import { FormatGame } from "tachi-common"; import { ArrayDiff } from "utils/misc"; import { GetUsersWithIDs, GetUserWithIDGuaranteed } from "utils/user"; import type { BulkWriteUpdateOneOperation } from "mongodb"; -import type { Game, integer, PBScoreDocument, Playtype, UserDocument } from "tachi-common"; +import type { Game, integer, PBScoreDocument, Playtype } from "tachi-common"; const logger = CreateLogCtx(__filename); @@ -271,14 +270,3 @@ export async function UpdatePlayersRivalRankings(userID: integer, game: Game, pl await db["personal-bests"].bulkWrite(bwrite, { ordered: false }); } - -export async function GetRivalRecentActivity( - user: UserDocument, - game: Game, - playtype: Playtype, - timespan: number -) { - const rivalIDs = await GetRivalIDs(user.id, game, playtype); - - return GetRecentActivity(rivalIDs, game, playtype, timespan); -} diff --git a/server/src/server/router/api/v1/games/_game/_playtype/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/router.ts index b9ab72651..bfdb3a7a7 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/router.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/router.ts @@ -1,21 +1,19 @@ import chartsRouter from "./charts/router"; import foldersRouter from "./folders/router"; import { ValidatePlaytypeFromParam } from "./middleware"; -import scoresRouter from "./scores/router"; import songIDRouter from "./songs/_songID/router"; import tablesRouter from "./tables/router"; import targetsRouter from "./targets/router"; import { Router } from "express"; import db from "external/mongo/db"; +import { CreateActivityRouteHandler } from "lib/activity/activity"; import { ONE_HOUR } from "lib/constants/time"; import { SearchUsersRegExp } from "lib/search/search"; import NodeCache from "node-cache"; -import p from "prudence"; import prValidate from "server/middleware/prudence-validate"; import { FormatGame, GetGamePTConfig } from "tachi-common"; import { GetRelevantSongsAndCharts } from "utils/db"; import { IsString } from "utils/misc"; -import { GetClassDistribution } from "utils/queries/stats"; import { GetGPT } from "utils/req-tachi-data"; import { CheckStrProfileAlg, @@ -24,7 +22,7 @@ import { } from "utils/string-checks"; import { GetUsersWithIDs } from "utils/user"; import type { FindOptions } from "monk"; -import type { Game, integer, Playtype, UserGameStats, gameClasses, IDStrings } from "tachi-common"; +import type { Game, integer, Playtype, UserGameStats } from "tachi-common"; const router: Router = Router({ mergeParams: true }); @@ -214,88 +212,6 @@ router.get("/pb-leaderboard", async (req, res) => { }); }); -/** - * Return the distribution of players for the provided class. - * - * @param class - This should be one of the games supported classes. - * - * @name GET /api/v1/games/:game/:playtype/class-distribution - */ -router.get( - "/class-distribution", - prValidate({ - class: "string", - }), - async (req, res) => { - const { game, playtype } = GetGPT(req); - const gptConfig = GetGamePTConfig(game, playtype); - - const stat = req.query.class as string; - - const supportedClasses = Object.keys(gptConfig.classHumanisedFormat); - - if (!supportedClasses.includes(stat)) { - return res.status(400).json({ - success: false, - description: `Invalid stat ${stat}. Expected any of ${supportedClasses.join( - ", " - )}.`, - }); - } - - const distribution = await GetClassDistribution( - game, - playtype, - stat as gameClasses.GameClassSets[IDStrings] - ); - - return res.status(200).json({ - success: true, - description: `Successfully retrieved distribution.`, - body: distribution, - }); - } -); - -/** - * Returns recent class improvements for this GPT. - * - * @param limit - How many to return. Defaults to 10, caps at 50. - * - * @name GET /api/v1/games/:game/:playtype/recent-classes - */ -router.get( - "/recent-classes", - prValidate({ - limit: p.optional((self) => p.isBoundedInteger(1, 50)(Number(self))), - }), - async (req, res) => { - const { game, playtype } = GetGPT(req); - - // validated to never be NaN by prudence. - const limit = req.query.limit !== undefined ? Number(req.query.limit) : 10; - - const recentClasses = await db["class-achievements"].find( - { - game, - playtype, - }, - { - limit, - sort: { timeAchieved: -1 }, - } - ); - - const users = await GetUsersWithIDs(recentClasses.map((e) => e.userID)); - - return res.status(200).json({ - success: true, - description: `Returned ${recentClasses.length} recent classes.`, - body: { classes: recentClasses, users }, - }); - } -); - /** * Search users that have played this game. * @@ -335,11 +251,27 @@ router.get( } ); +/** + * Retrieve activity for this GPT. + * + * @name GET /api/v1/games/:game/:playtype/activity + */ +router.get("/activity", (req, res) => { + const { game, playtype } = GetGPT(req); + + const route = CreateActivityRouteHandler({ + game, + playtype, + }); + + // this handles responding + void route(req, res); +}); + router.use("/charts", chartsRouter); router.use("/songs/:songID", songIDRouter); router.use("/folders", foldersRouter); router.use("/tables", tablesRouter); -router.use("/scores", scoresRouter); router.use("/targets", targetsRouter); export default router; diff --git a/server/src/server/router/api/v1/games/_game/_playtype/scores/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/scores/router.ts deleted file mode 100644 index 40c8aa3c1..000000000 --- a/server/src/server/router/api/v1/games/_game/_playtype/scores/router.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Router } from "express"; -import db from "external/mongo/db"; -import p from "prudence"; -import prValidate from "server/middleware/prudence-validate"; -import { GetRelevantSongsAndCharts } from "utils/db"; -import { GetGPT } from "utils/req-tachi-data"; -import { GetUsersWithIDs } from "utils/user"; - -const router: Router = Router({ mergeParams: true }); - -/** - * Return the most recent highlighted scores for this game. - * - * @param limit - Return up to this amount. Caps at 100, defaults to 100. - * - * @name GET /api/v1/games/:game/:playtype/scores/highlighted - */ -router.get( - "/highlighted", - prValidate({ - limit: p.optional((self) => p.isBoundedInteger(1, 100)(Number(self))), - }), - async (req, res) => { - const { game, playtype } = GetGPT(req); - - // validated to not be NaN by prudence. - const limit = req.query.limit !== undefined ? Number(req.query.limit) : 100; - - const scores = await db.scores.find( - { - game, - playtype, - highlight: true, - }, - { - limit, - sort: { timeAchieved: -1 }, - } - ); - - const users = await GetUsersWithIDs(scores.map((e) => e.userID)); - - const { songs, charts } = await GetRelevantSongsAndCharts(scores, game); - - return res.status(200).json({ - success: true, - description: `Returned ${scores.length} scores.`, - body: { - scores, - users, - songs, - charts, - }, - }); - } -); - -export default router; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts index b90fcdf38..2b23fd320 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts @@ -1,15 +1,9 @@ import { RequireAuthedAsUser } from "../../../../middleware"; import { Router } from "express"; import db from "external/mongo/db"; +import { CreateActivityRouteHandler } from "lib/activity/activity"; import { SetRivalsFailReasons } from "lib/constants/err-codes"; -import { ONE_MONTH, ONE_YEAR } from "lib/constants/time"; -import { - GetChallengerUsers, - GetRivalIDs, - GetRivalRecentActivity, - GetRivalUsers, - SetRivals, -} from "lib/rivals/rivals"; +import { GetChallengerUsers, GetRivalIDs, GetRivalUsers, SetRivals } from "lib/rivals/rivals"; import p from "prudence"; import { RequirePermissions } from "server/middleware/auth"; import prValidate from "server/middleware/prudence-validate"; @@ -177,31 +171,21 @@ router.get("/pb-leaderboard", async (req, res) => { /** * Retrieve activity for this user's set of rivals. * - * @param sessions - How many sessions' worth of activity do we want? - * This is a more reliable way of fetching *atleast some* activity than say, - * time. - * * @name GET /api/v1/users/:userID/games/:game/:playtype/rivals/activity */ -router.get( - "/activity", - prValidate({ sessions: p.optional(p.isIn("month", "3month", "year")) }), - async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +router.get("/activity", async (req, res) => { + const { game, playtype, user } = GetUGPT(req); - const qSessions = req.query.sessions as string | undefined; + const rivalIDs = await GetRivalIDs(user.id, game, playtype); - // defaulting to 30 seems sensible. - const sessions = qSessions ? Number(qSessions) : 30; + const route = CreateActivityRouteHandler({ + userID: { $in: rivalIDs }, + game, + playtype, + }); - const recentActivity = await GetRivalRecentActivity(user, game, playtype, sessions); - - return res.status(200).json({ - success: true, - description: `Retrieved recent rival activity.`, - body: recentActivity, - }); - } -); + // this handles responding + void route(req, res); +}); export default router; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts index 5a35730cc..3d5faa5a4 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts @@ -10,7 +10,7 @@ import tablesRouter from "./tables/router"; import targetsRouter from "./targets/router"; import { Router } from "express"; import db from "external/mongo/db"; -import { GetRecentActivity } from "lib/activity/activity"; +import { CreateActivityRouteHandler } from "lib/activity/activity"; import { ONE_MONTH, ONE_YEAR } from "lib/constants/time"; import p from "prudence"; import prValidate from "server/middleware/prudence-validate"; @@ -315,32 +315,20 @@ router.get("/leaderboard-adjacent", async (req, res) => { /** * Retrieve activity for this user. * - * @param sessions - How many sessions' worth of activity do we want? - * This is a more reliable way of fetching *atleast some* activity than say, - * time. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/rivals/activity + * @name GET /api/v1/users/:userID/games/:game/:playtype/activity */ -router.get( - "/activity", - prValidate({ sessions: p.optional((s) => p.isBoundedInteger(10, 100)(Number(s))) }), - async (req, res) => { - const { user, game, playtype } = GetUGPT(req); +router.get("/activity", (req, res) => { + const { game, playtype, user } = GetUGPT(req); - const qSessions = req.query.sessions as string | undefined; + const route = CreateActivityRouteHandler({ + userID: user.id, + game, + playtype, + }); - // defaulting to 30 seems sensible. - const sessions = qSessions ? Number(qSessions) : 30; - - const recentActivity = await GetRecentActivity([user.id], game, playtype, sessions); - - return res.status(200).json({ - success: true, - description: `Retrieved recent rival activity.`, - body: recentActivity, - }); - } -); + // this handles responding + void route(req, res); +}); router.use("/pbs", pbsRouter); router.use("/scores", scoresRouter); From 7bd215ac6f8658de953445ade09d5420c3b8dbb3 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Fri, 11 Nov 2022 04:59:40 +0000 Subject: [PATCH 6/7] fix: should fetch scores when startFrom passed mutating baseQuery broke subsequent queries. obvious in retrospect --- server/src/lib/activity/activity.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/server/src/lib/activity/activity.ts b/server/src/lib/activity/activity.ts index 54112d2f7..2e80e6250 100644 --- a/server/src/lib/activity/activity.ts +++ b/server/src/lib/activity/activity.ts @@ -35,16 +35,16 @@ export async function GetRecentActivity( sessions = 30, startFrom: number | null = null ) { - let baseQuery = query; + const baseQuery = query; - if (startFrom !== null) { - baseQuery = { - ...baseQuery, - timeStarted: { $lt: startFrom }, - }; - } + const initialSessionQuery = { + ...baseQuery, - const recentSessions = await db.sessions.find(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, }, From 9608bc87b057b9d1afcaac85935b84556c957859 Mon Sep 17 00:00:00 2001 From: zkldi <20380519+zkldi@users.noreply.github.com> Date: Fri, 11 Nov 2022 05:01:00 +0000 Subject: [PATCH 7/7] fix: highlight sessions if highlighted --- client/src/components/activity/Activity.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/activity/Activity.tsx b/client/src/components/activity/Activity.tsx index ed8284ba3..9c244ce25 100644 --- a/client/src/components/activity/Activity.tsx +++ b/client/src/components/activity/Activity.tsx @@ -265,7 +265,7 @@ function SessionActivity({ data, user }: { data: ClumpedActivitySession; user: U return (
-
+