mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-06 05:48:00 +03:00
feat: activity all the things
This commit is contained in:
@@ -17,11 +17,13 @@ import { UserContext } from "context/UserContext";
|
||||
import { AllLUGPTStatsContext } from "context/AllLUGPTStatsContext";
|
||||
import { UserSettingsContext } from "context/UserSettingsContext";
|
||||
import { ColourConfig, TachiConfig } from "lib/config";
|
||||
import React, { useContext } from "react";
|
||||
import React, { useContext, useMemo } from "react";
|
||||
import { Alert } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, Route, Switch } from "react-router-dom";
|
||||
import { COLOUR_SET, GetGameConfig, UserDocument } from "tachi-common";
|
||||
import { UGSWithRankingData, UserRecentSummary } from "types/api-returns";
|
||||
import { DashboardHeader } from "components/dashboard/DashboardHeader";
|
||||
import DashboardActivity from "components/dashboard/DashboardActivity";
|
||||
import { FolderInfoComponent } from "./users/games/_game/_playtype/folders/FolderSelectPage";
|
||||
import { GameStatContainer } from "./users/UserGamesPage";
|
||||
|
||||
@@ -37,29 +39,34 @@ export function DashboardPage() {
|
||||
return <DashboardNotLoggedIn />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{ugs?.length ? (
|
||||
<DashboardLoggedIn user={user} />
|
||||
) : (
|
||||
<DashboardLoggedInNoScores user={user} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
if (ugs?.length === 0) {
|
||||
return <DashboardLoggedInNoScores user={user} />;
|
||||
}
|
||||
|
||||
return <DashboardLoggedIn user={user} />;
|
||||
}
|
||||
|
||||
function DashboardLoggedIn({ user }: { user: UserDocument }) {
|
||||
const splash = useMemo(() => RFA(heySplashes), [user]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="display-4">
|
||||
{RFA(heySplashes)}, {user.username}.
|
||||
{splash}, {user.username}.
|
||||
</span>
|
||||
<Divider />
|
||||
<div className="card mt-4">
|
||||
<DashboardHeader />
|
||||
</div>
|
||||
<RecentInfo user={user} />
|
||||
<h1>Here's all your profiles.</h1>
|
||||
<Divider />
|
||||
|
||||
<UserGameStatsInfo user={user} />
|
||||
<Switch>
|
||||
<Route exact path="/dashboard">
|
||||
<DashboardActivity user={user} />
|
||||
</Route>
|
||||
<Route exact path="/dashboard/profiles">
|
||||
<UserGameStatsInfo user={user} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import React, { useContext, useEffect, useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { UserDocument } from "tachi-common";
|
||||
import Activity from "components/activity/Activity";
|
||||
|
||||
interface Props {
|
||||
reqUser: UserDocument;
|
||||
@@ -17,7 +18,14 @@ interface Props {
|
||||
export default function UserPage({ reqUser }: Props) {
|
||||
useSetSubheader(["Users", reqUser.username], [reqUser], `${reqUser.username}'s Profile`);
|
||||
|
||||
return <AboutMeCard reqUser={reqUser} />;
|
||||
return (
|
||||
<>
|
||||
<AboutMeCard reqUser={reqUser} />
|
||||
|
||||
<Divider />
|
||||
<Activity url={`/users/${reqUser.id}/activity`} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InferLinks(aboutMe: string) {
|
||||
|
||||
@@ -67,11 +67,13 @@ export default function DashboardRoutes() {
|
||||
<Layout>
|
||||
<DashboardErrorBoundary>
|
||||
<Switch>
|
||||
<Route exact path="/dashboard">
|
||||
{/* this is the easiest (read: least sucky) way of handling */}
|
||||
{/* these routes */}
|
||||
<Route exact path={["/dashboard", "/dashboard/profiles"]}>
|
||||
<DashboardPage />
|
||||
</Route>
|
||||
|
||||
<Route path="/dashboard/privacy">
|
||||
<Route exact path="/dashboard/privacy">
|
||||
<PrivacyPolicyPage />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -132,8 +132,6 @@ function SongChartRoutes({ game, playtype }: GamePT) {
|
||||
|
||||
const { settings } = useContext(UserSettingsContext);
|
||||
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const [activeChart, setActiveChart] = useState<ChartDocument | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ClumpActivity, GetUsers } from "util/activity";
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { ONE_HOUR } from "util/constants/time";
|
||||
import { CreateUserMap } from "util/data";
|
||||
import { NO_OP, TruncateString } from "util/misc";
|
||||
import { FormatTime, MillisToSince } from "util/time";
|
||||
import { ONE_HOUR } from "util/constants/time";
|
||||
import { ClumpActivity } from "util/activity";
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import ClassBadge from "components/game/ClassBadge";
|
||||
import SessionRaiseBreakdown from "components/sessions/SessionRaiseBreakdown";
|
||||
import ScoreTable from "components/tables/scores/ScoreTable";
|
||||
import ApiError from "components/util/ApiError";
|
||||
@@ -13,10 +14,12 @@ import LinkButton from "components/util/LinkButton";
|
||||
import Loading from "components/util/Loading";
|
||||
import Muted from "components/util/Muted";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Button, Col, Row } from "react-bootstrap";
|
||||
import { FormatChart, UserDocument } from "tachi-common";
|
||||
import { ActivityReturn, SessionReturns } from "types/api-returns";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FormatChart, FormatGame, UserDocument } from "tachi-common";
|
||||
import { ActivityReturn, RecordActivityReturn, SessionReturns } from "types/api-returns";
|
||||
import { UGPT } from "types/react";
|
||||
import { ScoreDataset } from "types/tables";
|
||||
import {
|
||||
ClumpedActivity,
|
||||
@@ -24,8 +27,8 @@ import {
|
||||
ClumpedActivityScores,
|
||||
ClumpedActivitySession,
|
||||
} from "types/tachi";
|
||||
import ClassBadge from "components/game/ClassBadge";
|
||||
import { Link } from "react-router-dom";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import ReferToUser from "components/util/ReferToUser";
|
||||
|
||||
// Records activity for a group of users on a GPT. Also used for single users.
|
||||
export default function Activity({
|
||||
@@ -41,8 +44,9 @@ export default function Activity({
|
||||
}) {
|
||||
const [clumped, setClumped] = useState<ClumpedActivity>([]);
|
||||
const [users, setUsers] = useState<Array<UserDocument>>([]);
|
||||
const [shouldShowGame, setShouldShowGame] = useState(false);
|
||||
|
||||
const { data, error } = useApiQuery<ActivityReturn>(url);
|
||||
const { data, error } = useApiQuery<ActivityReturn | RecordActivityReturn>(url);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
@@ -50,7 +54,11 @@ export default function Activity({
|
||||
setUsers([]);
|
||||
} else {
|
||||
setClumped(ClumpActivity(data));
|
||||
setUsers(data.users);
|
||||
setUsers(GetUsers(data));
|
||||
|
||||
// show game if this is { "iidx:SP": [], "iidx:DP": [] }...
|
||||
// to disambiguate
|
||||
setShouldShowGame(!("users" in data));
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
@@ -68,15 +76,18 @@ export default function Activity({
|
||||
|
||||
return (
|
||||
<ActivityInner
|
||||
shouldShowGame={shouldShowGame}
|
||||
data={clumped}
|
||||
users={users}
|
||||
fetchMoreFrom={(start) => {
|
||||
APIFetchV1<ActivityReturn>(`${url}?startTime=${start}`).then((r) => {
|
||||
if (r.success) {
|
||||
setUsers([...users, ...r.body.users]);
|
||||
setClumped([...clumped, ...ClumpActivity(r.body)]);
|
||||
APIFetchV1<ActivityReturn | RecordActivityReturn>(`${url}?startTime=${start}`).then(
|
||||
(r) => {
|
||||
if (r.success) {
|
||||
setClumped([...clumped, ...ClumpActivity(r.body)]);
|
||||
setUsers([...users, ...GetUsers(r.body)]);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -86,10 +97,12 @@ function ActivityInner({
|
||||
data,
|
||||
users,
|
||||
fetchMoreFrom,
|
||||
shouldShowGame,
|
||||
}: {
|
||||
data: ClumpedActivity;
|
||||
users: Array<UserDocument>;
|
||||
fetchMoreFrom: (start: number) => void;
|
||||
shouldShowGame: boolean;
|
||||
}) {
|
||||
const userMap = CreateUserMap(users);
|
||||
|
||||
@@ -107,11 +120,29 @@ function ActivityInner({
|
||||
|
||||
switch (e.type) {
|
||||
case "SCORES":
|
||||
return <ScoresActivity data={e} user={user} />;
|
||||
return (
|
||||
<ScoresActivity
|
||||
shouldShowGame={shouldShowGame}
|
||||
data={e}
|
||||
user={user}
|
||||
/>
|
||||
);
|
||||
case "SESSION":
|
||||
return <SessionActivity data={e} user={user} />;
|
||||
return (
|
||||
<SessionActivity
|
||||
shouldShowGame={shouldShowGame}
|
||||
data={e}
|
||||
user={user}
|
||||
/>
|
||||
);
|
||||
case "CLASS_ACHIEVEMENT":
|
||||
return <ClassAchievementActivity data={e} user={user} />;
|
||||
return (
|
||||
<ClassAchievementActivity
|
||||
shouldShowGame={shouldShowGame}
|
||||
data={e}
|
||||
user={user}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
<div className="timeline-item">
|
||||
@@ -160,9 +191,19 @@ function ActivityInner({
|
||||
);
|
||||
}
|
||||
|
||||
function ScoresActivity({ data, user }: { data: ClumpedActivityScores; user: UserDocument }) {
|
||||
function ScoresActivity({
|
||||
data,
|
||||
user,
|
||||
shouldShowGame,
|
||||
}: {
|
||||
data: ClumpedActivityScores;
|
||||
user: UserDocument;
|
||||
shouldShowGame: boolean;
|
||||
}) {
|
||||
const { game, playtype } = data.scores[0];
|
||||
|
||||
const prettyGame = shouldShowGame ? `${FormatGame(game, playtype)} ` : "";
|
||||
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
let subMessage;
|
||||
@@ -171,7 +212,7 @@ function ScoresActivity({ data, user }: { data: ClumpedActivityScores; user: Use
|
||||
if (data.scores.length === 1) {
|
||||
const score0 = data.scores[0];
|
||||
|
||||
subMessage = `a score on ${FormatChart(
|
||||
subMessage = `a ${prettyGame}score on ${FormatChart(
|
||||
score0.game,
|
||||
score0.__related.song,
|
||||
score0.__related.chart
|
||||
@@ -181,11 +222,11 @@ function ScoresActivity({ data, user }: { data: ClumpedActivityScores; user: Use
|
||||
mutedText = `"${score0.comment}"`;
|
||||
}
|
||||
} else {
|
||||
subMessage = `${data.scores.length} scores`;
|
||||
subMessage = `${data.scores.length} ${prettyGame}scores`;
|
||||
|
||||
mutedText = TruncateString(
|
||||
data.scores
|
||||
.map((e) => FormatChart(e.game, e.__related.song, e.__related.chart))
|
||||
.map((e) => FormatChart(e.game, e.__related.song, e.__related.chart, true))
|
||||
.join(", "),
|
||||
100
|
||||
);
|
||||
@@ -223,7 +264,8 @@ function ScoresActivity({ data, user }: { data: ClumpedActivityScores; user: Use
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: "1.15rem" }} className="ml-2">
|
||||
{user.username} highlighted {subMessage}!
|
||||
<UGPTLink reqUser={user} game={game} playtype={playtype} /> highlighted{" "}
|
||||
{subMessage}!
|
||||
</span>
|
||||
{mutedText && (
|
||||
<>
|
||||
@@ -258,8 +300,19 @@ function ScoresActivity({ data, user }: { data: ClumpedActivityScores; user: Use
|
||||
);
|
||||
}
|
||||
|
||||
function SessionActivity({ data, user }: { data: ClumpedActivitySession; user: UserDocument }) {
|
||||
function SessionActivity({
|
||||
data,
|
||||
user,
|
||||
shouldShowGame,
|
||||
}: {
|
||||
data: ClumpedActivitySession;
|
||||
user: UserDocument;
|
||||
shouldShowGame: boolean;
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
const { user: loggedInUser } = useContext(UserContext);
|
||||
|
||||
const prettyGame = shouldShowGame ? `${FormatGame(data.game, data.playtype)} ` : "";
|
||||
|
||||
const isProbablyActive = Date.now() - data.timeEnded < ONE_HOUR;
|
||||
|
||||
@@ -292,9 +345,21 @@ function SessionActivity({ data, user }: { data: ClumpedActivitySession; user: U
|
||||
fontSize: isProbablyActive ? "1.2rem" : undefined,
|
||||
}}
|
||||
>
|
||||
{user.username} {isProbablyActive ? "is having" : "had"} a session '
|
||||
{data.name}' with {data.scoreInfo.length}{" "}
|
||||
{data.scoreInfo.length === 1 ? "score" : "scores"}.
|
||||
{/* worst string formatting ever */}
|
||||
<UGPTLink
|
||||
reqUser={user}
|
||||
game={data.game}
|
||||
playtype={data.playtype}
|
||||
/>{" "}
|
||||
{isProbablyActive
|
||||
? user.id === loggedInUser?.id
|
||||
? "are having"
|
||||
: "is having"
|
||||
: "had"}{" "}
|
||||
a {prettyGame}
|
||||
session '{data.name}' with {data.scoreInfo.length}{" "}
|
||||
{data.scoreInfo.length === 1 ? "score" : "scores"}
|
||||
{data.highlight ? "!" : "."}
|
||||
</span>
|
||||
<br />
|
||||
{data.desc && data.desc !== "This session has no description." && (
|
||||
@@ -328,7 +393,7 @@ function SessionShower({ sessionID }: { sessionID: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Row className="mt-4">
|
||||
<SessionRaiseBreakdown sessionData={data} setScores={NO_OP} />
|
||||
<Col xs={12}>
|
||||
<Divider />
|
||||
@@ -348,9 +413,11 @@ function SessionShower({ sessionID }: { sessionID: string }) {
|
||||
function ClassAchievementActivity({
|
||||
data,
|
||||
user,
|
||||
shouldShowGame,
|
||||
}: {
|
||||
data: ClumpedActivityClassAchievement;
|
||||
user: UserDocument;
|
||||
shouldShowGame: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="timeline-item timeline-hover">
|
||||
@@ -365,12 +432,7 @@ function ClassAchievementActivity({
|
||||
>
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div className="mr-3" style={{ width: "70%", textAlign: "left" }}>
|
||||
<Link
|
||||
to={`/dashboard/users/${user.username}/games/${data.game}/${data.playtype}`}
|
||||
className="gentle-link"
|
||||
>
|
||||
{user.username}
|
||||
</Link>{" "}
|
||||
<UGPTLink reqUser={user} game={data.game} playtype={data.playtype} />{" "}
|
||||
achieved{" "}
|
||||
<ClassBadge
|
||||
classSet={data.classSet}
|
||||
@@ -378,6 +440,7 @@ function ClassAchievementActivity({
|
||||
playtype={data.playtype}
|
||||
classValue={data.classValue}
|
||||
/>
|
||||
{shouldShowGame && ` in ${FormatGame(data.game, data.playtype)}`}!
|
||||
{data.classOldValue !== null && (
|
||||
<>
|
||||
{" "}
|
||||
@@ -405,3 +468,20 @@ function ClassAchievementActivity({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UGPTLink({ reqUser, game, playtype }: UGPT) {
|
||||
// currently
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/dashboard/users/${reqUser.username}/games/${game}/${playtype}`}
|
||||
className="gentle-link"
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{user?.id === reqUser.id ? "You" : reqUser.username}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import Activity from "components/activity/Activity";
|
||||
import React from "react";
|
||||
import { UserDocument } from "tachi-common";
|
||||
|
||||
export default function DashboardActivity({ user }: { user: UserDocument }) {
|
||||
return (
|
||||
<>
|
||||
<div className="display-4 mb-4">Here's what you and your rivals have been up to!</div>
|
||||
<Activity url={`/users/${user.id}/activity?includeRivals=true`} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Navbar from "components/nav/Navbar";
|
||||
import NavItem from "components/nav/NavItem";
|
||||
import React from "react";
|
||||
|
||||
export function DashboardHeader() {
|
||||
const navItems = [
|
||||
<NavItem key="activity" to="/dashboard">
|
||||
Activity
|
||||
</NavItem>,
|
||||
<NavItem key="profiles" to="/dashboard/profiles">
|
||||
Your Profiles
|
||||
</NavItem>,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="row align-items-center mb-0">
|
||||
<Navbar>{navItems}</Navbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,48 +47,6 @@ export default function SessionRaiseBreakdown({
|
||||
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
// todo infer default table
|
||||
// const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const [chartIDs, setChartIDs] = useState<string[] | null>([]);
|
||||
const [tableID, setTableID] = useState<string | null>(null);
|
||||
const [tableFolders, setTableFolders] = useState<FolderDocument[]>([]);
|
||||
const [folder, setFolder] = useState<FolderDocument | null>(null);
|
||||
const [filter, setFilter] = useState<"folder" | "all" | "highlighted">("all");
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableID) {
|
||||
return;
|
||||
}
|
||||
|
||||
APIFetchV1(`/games/${game}/${playtype}/tables/${tableID}`).then((r) => {
|
||||
if (!r.success) {
|
||||
throw new Error(r.description);
|
||||
}
|
||||
|
||||
// @ts-expect-error todo
|
||||
setTableFolders(r.body.folders);
|
||||
// @ts-expect-error todo
|
||||
setFolder(r.body.folders[r.body.folders.length - 1]);
|
||||
});
|
||||
}, [tableID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filter !== "folder" || !folder) {
|
||||
setChartIDs(null);
|
||||
return;
|
||||
}
|
||||
|
||||
APIFetchV1(`/games/${game}/${playtype}/folders/${folder.folderID}`).then((r) => {
|
||||
if (!r.success) {
|
||||
throw new Error(r.description);
|
||||
}
|
||||
|
||||
// @ts-expect-error todo
|
||||
setChartIDs(r.body.charts.map((e) => e.chartID));
|
||||
});
|
||||
}, [folder, tableID, filter]);
|
||||
|
||||
const { data, error } = useQuery(`/games/${game}/${playtype}/tables`, async () => {
|
||||
const res = await APIFetchV1<TableDocument[]>(`/games/${game}/${playtype}/tables`);
|
||||
|
||||
@@ -100,12 +58,6 @@ export default function SessionRaiseBreakdown({
|
||||
});
|
||||
const [view, setView] = useState<"lamps" | "both" | "grades">("both");
|
||||
|
||||
useEffect(() => {
|
||||
if (tableID === null && (data?.length ?? 0) > 0) {
|
||||
setTableID(data![0].tableID);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (error) {
|
||||
return <>{(error as Error).message}</>;
|
||||
}
|
||||
@@ -138,54 +90,6 @@ export default function SessionRaiseBreakdown({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-3 d-none d-lg-block">
|
||||
<select
|
||||
className="form-control ml-auto"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as any)}
|
||||
>
|
||||
<option value="all">No Filter</option>
|
||||
<option value="folder">Folder Filters</option>
|
||||
<option value="highlighted">Highlights Only</option>
|
||||
</select>
|
||||
</div>
|
||||
{filter === "folder" && (
|
||||
<>
|
||||
<div className="col-12">
|
||||
<Divider className="mt-4 mb-4" />
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<select
|
||||
className="form-control"
|
||||
onChange={(e) => setTableID(e.target.value)}
|
||||
>
|
||||
{data.map((e) => (
|
||||
<option key={e.tableID} value={e.tableID}>
|
||||
{e.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<select
|
||||
className="form-control"
|
||||
value={folder?.folderID}
|
||||
onChange={(e) =>
|
||||
setFolder(
|
||||
tableFolders.find((f) => f.folderID === e.target.value)!
|
||||
)
|
||||
}
|
||||
>
|
||||
{tableFolders.map((e) => (
|
||||
<option key={e.folderID} value={e.folderID}>
|
||||
{e.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider className="mt-4 mb-4" />
|
||||
@@ -196,22 +100,18 @@ export default function SessionRaiseBreakdown({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SessionScoreStatBreakdown {...{ sessionData, chartIDs, filter, view, setScores }} />
|
||||
<SessionScoreStatBreakdown {...{ sessionData, view, setScores }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionScoreStatBreakdown({
|
||||
sessionData,
|
||||
chartIDs,
|
||||
filter,
|
||||
view,
|
||||
setScores,
|
||||
}: {
|
||||
sessionData: SessionReturns;
|
||||
setScores: SetScores;
|
||||
chartIDs: string[] | null;
|
||||
filter: "folder" | "all" | "highlighted";
|
||||
view: "lamps" | "both" | "grades";
|
||||
}) {
|
||||
const songMap = CreateSongMap(sessionData.songs);
|
||||
@@ -236,12 +136,6 @@ function SessionScoreStatBreakdown({
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filter === "folder" && chartIDs && !chartIDs.includes(score.chartID)) {
|
||||
continue;
|
||||
} else if (filter === "highlighted" && !score.highlight) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (scoreInfo.isNewScore) {
|
||||
PartialArrayRecordAssign(newLamps, score.scoreData.lamp, { score, scoreInfo });
|
||||
PartialArrayRecordAssign(newGrades, score.scoreData.grade, { score, scoreInfo });
|
||||
@@ -260,7 +154,7 @@ function SessionScoreStatBreakdown({
|
||||
}
|
||||
|
||||
return [newLamps, newGrades];
|
||||
}, [chartIDs, filter, view]);
|
||||
}, [view]);
|
||||
|
||||
const gptConfig = GetGamePTConfig(sessionData.session.game, sessionData.session.playtype);
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@ import NavItem from "components/nav/NavItem";
|
||||
import MiniTable from "components/tables/components/MiniTable";
|
||||
import Divider from "components/util/Divider";
|
||||
import React from "react";
|
||||
import { Game, UserDocument } from "tachi-common";
|
||||
import { Game, UserDocument, Playtype } from "tachi-common";
|
||||
import { UGPTStatsReturn } from "types/api-returns";
|
||||
import { Playtype } from "tachi-common";
|
||||
import ProfileBadges from "./ProfileBadges";
|
||||
import ProfilePicture from "./ProfilePicture";
|
||||
import RankingData from "./UGPTRankingData";
|
||||
@@ -86,7 +85,7 @@ export function UGPTBottomNav({
|
||||
}) {
|
||||
const navItems = [
|
||||
<NavItem key="overview" to={`${baseUrl}/`}>
|
||||
Overview
|
||||
Overview & Activity
|
||||
</NavItem>,
|
||||
<NavItem key="scores" to={`${baseUrl}/scores`}>
|
||||
Scores
|
||||
|
||||
@@ -229,3 +229,5 @@ export interface ActivityReturn {
|
||||
|
||||
users: Array<UserDocument>;
|
||||
}
|
||||
|
||||
export type RecordActivityReturn = Partial<Record<IDStrings, ActivityReturn>>;
|
||||
|
||||
+35
-14
@@ -1,14 +1,36 @@
|
||||
import { ActivityReturn } from "types/api-returns";
|
||||
import { ActivityReturn, RecordActivityReturn } from "types/api-returns";
|
||||
import { ClumpedActivity, ClumpedActivityScores } from "types/tachi";
|
||||
import { ONE_HOUR } from "./constants/time";
|
||||
import { CreateSongMap, CreateChartMap } from "./data";
|
||||
import { NumericSOV } from "./sorts";
|
||||
|
||||
const SORT_ACTIVITY = NumericSOV((x: ClumpedActivity[0]) => {
|
||||
switch (x.type) {
|
||||
case "SESSION":
|
||||
return x.timeStarted;
|
||||
case "SCORES":
|
||||
return x.scores[0]?.timeAchieved ?? -Infinity;
|
||||
case "CLASS_ACHIEVEMENT":
|
||||
return x.timeAchieved;
|
||||
}
|
||||
}, true);
|
||||
|
||||
/**
|
||||
* Given recent activity data, clump it together so it's easier to work with,
|
||||
* by joining repeat-highlighted scores from the same user.
|
||||
*
|
||||
* This also supports multiple games, i.e. from /users/:userID/activity.
|
||||
*/
|
||||
export function ClumpActivity(data: ActivityReturn): ClumpedActivity {
|
||||
export function ClumpActivity(data: ActivityReturn | RecordActivityReturn): ClumpedActivity {
|
||||
if (!("songs" in data)) {
|
||||
const act = [];
|
||||
for (const activity of Object.values(data)) {
|
||||
act.push(...ClumpActivity(activity));
|
||||
}
|
||||
|
||||
return act.sort(SORT_ACTIVITY);
|
||||
}
|
||||
|
||||
const songMap = CreateSongMap(data.songs);
|
||||
const chartMap = CreateChartMap(data.charts);
|
||||
|
||||
@@ -68,16 +90,15 @@ export function ClumpActivity(data: ActivityReturn): ClumpedActivity {
|
||||
});
|
||||
}
|
||||
|
||||
return clumped.sort(
|
||||
NumericSOV((x) => {
|
||||
switch (x.type) {
|
||||
case "SESSION":
|
||||
return x.timeStarted;
|
||||
case "SCORES":
|
||||
return x.scores[0]?.timeAchieved ?? -Infinity;
|
||||
case "CLASS_ACHIEVEMENT":
|
||||
return x.timeAchieved;
|
||||
}
|
||||
}, true)
|
||||
);
|
||||
return clumped.sort(SORT_ACTIVITY);
|
||||
}
|
||||
|
||||
export function GetUsers(data: ActivityReturn | RecordActivityReturn) {
|
||||
if ("users" in data) {
|
||||
return data.users;
|
||||
}
|
||||
|
||||
return Object.values(data)
|
||||
.map((e) => e.users)
|
||||
.flat();
|
||||
}
|
||||
|
||||
@@ -72,7 +72,12 @@ export function FormatGame(game: Game, playtype: Playtypes[Game]): string {
|
||||
return `${gameConfig.name} (${playtype})`;
|
||||
}
|
||||
|
||||
export function FormatChart(game: Game, song: SongDocument, chart: ChartDocument): string {
|
||||
export function FormatChart(
|
||||
game: Game,
|
||||
song: SongDocument,
|
||||
chart: ChartDocument,
|
||||
short = false
|
||||
): string {
|
||||
if (game === "bms") {
|
||||
const tables = (chart as ChartDocument<"bms:7K" | "bms:14K">).data.tableFolders;
|
||||
|
||||
@@ -124,19 +129,27 @@ export function FormatChart(game: Game, song: SongDocument, chart: ChartDocument
|
||||
|
||||
const gameConfig = GetGameConfig(game);
|
||||
|
||||
let playtypeStr = `${chart.playtype} `;
|
||||
let playtypeStr = `${chart.playtype}`;
|
||||
|
||||
if (gameConfig.validPlaytypes.length === 1) {
|
||||
playtypeStr = "";
|
||||
}
|
||||
|
||||
const gptConfig = GetGamePTConfig(game, chart.playtype);
|
||||
|
||||
const diff = short ? gptConfig.shortDifficulties[chart.difficulty] : chart.difficulty;
|
||||
|
||||
// iidx formats things like SPA instead of SP A.
|
||||
// this is a hack, this should be part of the gptConfig, tbh.
|
||||
const space = game !== "iidx" ? " " : "";
|
||||
|
||||
// return the most recent version this chart appeared in if it
|
||||
// is not primary.
|
||||
if (!chart.isPrimary) {
|
||||
return `${song.title} (${playtypeStr}${chart.difficulty} ${chart.level} ${chart.versions[0]})`;
|
||||
return `${song.title} (${playtypeStr}${space}${diff} ${chart.level} ${chart.versions[0]})`;
|
||||
}
|
||||
|
||||
return `${song.title} (${playtypeStr}${chart.difficulty} ${chart.level})`;
|
||||
return `${song.title} (${playtypeStr}${space}${diff} ${chart.level})`;
|
||||
}
|
||||
|
||||
export function AbsoluteGradeDelta<I extends IDStrings = IDStrings>(
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { Request, Response } from "express-serve-static-core";
|
||||
import type { FilterQuery } from "mongodb";
|
||||
import type { ClassAchievementDocument, Game, ScoreDocument, SessionDocument } from "tachi-common";
|
||||
|
||||
type ActivityConstraint = FilterQuery<ClassAchievementDocument & ScoreDocument & SessionDocument>;
|
||||
export type ActivityConstraint = FilterQuery<
|
||||
ClassAchievementDocument & ScoreDocument & SessionDocument
|
||||
>;
|
||||
|
||||
/**
|
||||
* Retrieves recent activity for this group of users for this GPT.
|
||||
|
||||
@@ -11,9 +11,11 @@ import settingsRouter from "./settings/router";
|
||||
import { HashPassword, PasswordCompare, ValidatePassword } from "../../auth/auth";
|
||||
import { Router } from "express";
|
||||
import db from "external/mongo/db";
|
||||
import { GetRecentActivity } from "lib/activity/activity";
|
||||
import { SYMBOL_TACHI_DATA } from "lib/constants/tachi";
|
||||
import { ONE_MONTH } from "lib/constants/time";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { GetRivalIDs } from "lib/rivals/rivals";
|
||||
import p from "prudence";
|
||||
import prValidate from "server/middleware/prudence-validate";
|
||||
import { DeleteUndefinedProps, IsNonEmptyString, NotNullish, StripUrl } from "utils/misc";
|
||||
@@ -25,7 +27,8 @@ import {
|
||||
GetRecentSessions,
|
||||
} from "utils/queries/summary";
|
||||
import { FormatUserDoc, GetAllRankings, GetUserWithID } from "utils/user";
|
||||
import type { ImportTypes, integer, UserGameStats } from "tachi-common";
|
||||
import type { ActivityConstraint } from "lib/activity/activity";
|
||||
import type { IDStrings, ImportTypes, integer, UserGameStats } from "tachi-common";
|
||||
import type { ProfileRatingAlgs } from "utils/string-checks";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
@@ -411,6 +414,72 @@ router.get("/recent-imports", async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetch this users recent activity, and all of their rivals for each GPT they've played.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/activity
|
||||
*/
|
||||
router.use(
|
||||
"/activity",
|
||||
prValidate({
|
||||
startTime: "*string",
|
||||
includeRivals: p.optional(p.isIn("true", "false")),
|
||||
}),
|
||||
async (req, res) => {
|
||||
const qStartTime = req.query.startTime as string | undefined;
|
||||
|
||||
const includeRivals = req.query.includeRivals === "true";
|
||||
|
||||
const startTime = qStartTime ? Number(qStartTime) : null;
|
||||
|
||||
if (Number.isNaN(startTime)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid startTime, got a non number.`,
|
||||
});
|
||||
}
|
||||
|
||||
const user = NotNullish(req[SYMBOL_TACHI_DATA]?.requestedUser);
|
||||
|
||||
const gpts = await db["game-stats"].find({
|
||||
userID: user.id,
|
||||
});
|
||||
|
||||
const data: Partial<Record<IDStrings, unknown>> = {};
|
||||
|
||||
await Promise.all(
|
||||
gpts.map(async (e) => {
|
||||
let userID: ActivityConstraint["userID"] = user.id;
|
||||
|
||||
if (includeRivals) {
|
||||
const rivalIDs = await GetRivalIDs(user.id, e.game, e.playtype);
|
||||
|
||||
userID = { $in: [user.id, ...rivalIDs] };
|
||||
}
|
||||
|
||||
const activity = await GetRecentActivity(
|
||||
e.game,
|
||||
{
|
||||
game: e.game,
|
||||
playtype: e.playtype,
|
||||
userID,
|
||||
},
|
||||
30,
|
||||
startTime
|
||||
);
|
||||
|
||||
data[`${e.game}:${e.playtype}` as IDStrings] = activity;
|
||||
})
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned recent activity.`,
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
router.use("/games/:game/:playtype", gamePTRouter);
|
||||
router.use("/pfp", pfpRouter);
|
||||
router.use("/banner", bannerRouter);
|
||||
|
||||
Reference in New Issue
Block a user