mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-09 07:09:57 +03:00
677 lines
17 KiB
TypeScript
677 lines
17 KiB
TypeScript
import { ClumpActivity, GetUsers } from "util/activity";
|
|
import { APIFetchV1 } from "util/api";
|
|
import { ONE_HOUR } from "util/constants/time";
|
|
import { CreateScoreIDMap, CreateUserMap } from "util/data";
|
|
import { NO_OP, TruncateString } from "util/misc";
|
|
import { FormatTime, MillisToSince } from "util/time";
|
|
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";
|
|
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 { UserContext } from "context/UserContext";
|
|
import React, { useContext, useEffect, useState } from "react";
|
|
import { Button, Col, Row } from "react-bootstrap";
|
|
import { Link } from "react-router-dom";
|
|
import { FormatChart, FormatGame, GetGamePTConfig, UserDocument } from "tachi-common";
|
|
import { ActivityReturn, RecordActivityReturn, SessionReturns } from "types/api-returns";
|
|
import { UGPT } from "types/react";
|
|
import { ScoreDataset } from "types/tables";
|
|
import {
|
|
ClumpedActivity,
|
|
ClumpedActivityClassAchievement,
|
|
ClumpedActivityGoalAchievement,
|
|
ClumpedActivityQuestAchievement,
|
|
ClumpedActivityScores,
|
|
ClumpedActivitySession,
|
|
} from "types/tachi";
|
|
import { InnerQuestSectionGoal } from "components/targets/quests/Quest";
|
|
|
|
// Records activity for a group of users on a GPT. Also used for single users.
|
|
export default function Activity({
|
|
url,
|
|
handleNoActivity = (
|
|
<Col xs={12} className="text-center">
|
|
We found no activity!
|
|
</Col>
|
|
),
|
|
}: {
|
|
url: string;
|
|
handleNoActivity?: React.ReactNode;
|
|
}) {
|
|
const [clumped, setClumped] = useState<ClumpedActivity>([]);
|
|
const [users, setUsers] = useState<Array<UserDocument>>([]);
|
|
const [shouldShowGame, setShouldShowGame] = useState(false);
|
|
const [exhausted, setExhausted] = useState(false);
|
|
|
|
const { data, error } = useApiQuery<ActivityReturn | RecordActivityReturn>(url);
|
|
|
|
useEffect(() => {
|
|
if (!data) {
|
|
setClumped([]);
|
|
setUsers([]);
|
|
} else {
|
|
const newActivity = ClumpActivity(data);
|
|
|
|
if (newActivity.filter((e) => e.type === "SESSION").length < 30) {
|
|
setExhausted(true);
|
|
}
|
|
|
|
setClumped(newActivity);
|
|
setUsers(GetUsers(data));
|
|
|
|
// show game if this is { "iidx:SP": [], "iidx:DP": [] }...
|
|
// to disambiguate
|
|
setShouldShowGame(!("users" in data));
|
|
}
|
|
}, [data]);
|
|
|
|
if (error) {
|
|
return <ApiError error={error} />;
|
|
}
|
|
|
|
if (!data) {
|
|
return <Loading />;
|
|
}
|
|
|
|
if (clumped.length === 0) {
|
|
return <>{handleNoActivity}</>;
|
|
}
|
|
|
|
return (
|
|
<ActivityInner
|
|
shouldShowGame={shouldShowGame}
|
|
data={clumped}
|
|
users={users}
|
|
exhausted={exhausted}
|
|
fetchMoreFrom={(start) => {
|
|
APIFetchV1<ActivityReturn | RecordActivityReturn>(`${url}?startTime=${start}`).then(
|
|
(r) => {
|
|
if (r.success) {
|
|
const newActivity = ClumpActivity(r.body);
|
|
|
|
if (newActivity.filter((e) => e.type === "SESSION").length < 30) {
|
|
setExhausted(true);
|
|
}
|
|
|
|
setClumped([...clumped, ...newActivity]);
|
|
setUsers([...users, ...GetUsers(r.body)]);
|
|
}
|
|
}
|
|
);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ActivityInner({
|
|
data,
|
|
users,
|
|
fetchMoreFrom,
|
|
shouldShowGame,
|
|
exhausted,
|
|
}: {
|
|
data: ClumpedActivity;
|
|
users: Array<UserDocument>;
|
|
fetchMoreFrom: (start: number) => void;
|
|
shouldShowGame: boolean;
|
|
exhausted: boolean;
|
|
}) {
|
|
const userMap = CreateUserMap(users);
|
|
|
|
return (
|
|
<Col xs={12} className="text-center">
|
|
Tip: You can click on an event to learn more about it.
|
|
<div className="timeline activity-timeline timeline-2 mt-4">
|
|
<div className="timeline-bar"></div>
|
|
{data.map((e) => {
|
|
const user = userMap.get(e.type === "SCORES" ? e.scores[0]?.userID : e.userID);
|
|
|
|
if (!user) {
|
|
return <div>This user doesn't exist? Whoops.</div>;
|
|
}
|
|
|
|
switch (e.type) {
|
|
case "SCORES":
|
|
return (
|
|
<ScoresActivity
|
|
shouldShowGame={shouldShowGame}
|
|
data={e}
|
|
user={user}
|
|
/>
|
|
);
|
|
case "SESSION":
|
|
return (
|
|
<SessionActivity
|
|
shouldShowGame={shouldShowGame}
|
|
data={e}
|
|
user={user}
|
|
/>
|
|
);
|
|
case "CLASS_ACHIEVEMENT":
|
|
return (
|
|
<ClassAchievementActivity
|
|
shouldShowGame={shouldShowGame}
|
|
data={e}
|
|
user={user}
|
|
/>
|
|
);
|
|
case "GOAL_ACHIEVEMENTS":
|
|
return (
|
|
<GoalActivity
|
|
shouldShowGame={shouldShowGame}
|
|
data={e}
|
|
user={user}
|
|
/>
|
|
);
|
|
case "QUEST_ACHIEVEMENT":
|
|
return (
|
|
<QuestActivity
|
|
shouldShowGame={shouldShowGame}
|
|
data={e}
|
|
user={user}
|
|
/>
|
|
);
|
|
}
|
|
})}
|
|
<div className="timeline-item">
|
|
<div className="timeline-item">
|
|
<div className="timeline-badge bg-success"></div>
|
|
<div
|
|
className="timeline-content d-flex"
|
|
style={{
|
|
flexDirection: "column",
|
|
flexWrap: "wrap",
|
|
marginRight: "2rem",
|
|
}}
|
|
>
|
|
{exhausted ? (
|
|
<>No more activity. This is the end of the road!</>
|
|
) : (
|
|
<Button
|
|
variant="outline-primary"
|
|
onClick={() => {
|
|
let lastTimestamp;
|
|
const lastThing = data.at(-1)!;
|
|
|
|
switch (lastThing.type) {
|
|
case "SCORES":
|
|
lastTimestamp = lastThing.scores[0]?.timeAchieved;
|
|
break;
|
|
case "CLASS_ACHIEVEMENT":
|
|
lastTimestamp = lastThing.timeAchieved;
|
|
break;
|
|
case "SESSION":
|
|
lastTimestamp = lastThing.timeStarted;
|
|
break;
|
|
case "GOAL_ACHIEVEMENTS":
|
|
lastTimestamp = lastThing.goals[0]?.timeAchieved;
|
|
break;
|
|
case "QUEST_ACHIEVEMENT":
|
|
lastTimestamp = lastThing.sub.timeAchieved;
|
|
}
|
|
|
|
if (!lastTimestamp) {
|
|
alert("Failed. What?");
|
|
return;
|
|
}
|
|
|
|
fetchMoreFrom(lastTimestamp);
|
|
}}
|
|
>
|
|
Load More...
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Col>
|
|
);
|
|
}
|
|
|
|
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;
|
|
let mutedText: string | null | undefined;
|
|
|
|
if (data.scores.length === 1) {
|
|
const score0 = data.scores[0];
|
|
|
|
subMessage = `a ${prettyGame}score on ${FormatChart(
|
|
score0.game,
|
|
score0.__related.song,
|
|
score0.__related.chart,
|
|
true
|
|
)}`;
|
|
|
|
if (score0.comment) {
|
|
mutedText = `"${score0.comment}"`;
|
|
}
|
|
} else {
|
|
subMessage = `${data.scores.length} ${prettyGame}scores`;
|
|
|
|
mutedText = TruncateString(
|
|
data.scores
|
|
.map((e) => FormatChart(e.game, e.__related.song, e.__related.chart, true))
|
|
.join(", "),
|
|
100
|
|
);
|
|
}
|
|
|
|
const dataset: ScoreDataset = data.scores.map((e, i) => ({
|
|
...e,
|
|
__related: {
|
|
...e.__related,
|
|
index: i,
|
|
user,
|
|
},
|
|
}));
|
|
|
|
return (
|
|
<div className="timeline-item timeline-hover my-4">
|
|
<div className="timeline-badge bg-warning"></div>
|
|
<div className="timeline-content">
|
|
<div className="timeline-content-inner" onClick={() => setShow(!show)}>
|
|
<div className="timeline-content-title">
|
|
<Icon
|
|
type={`chevron-${show ? "down" : "right"}`}
|
|
style={{
|
|
fontSize: "0.75rem",
|
|
}}
|
|
/>
|
|
<span style={{ fontSize: "1.15rem" }} className="ml-2">
|
|
<UGPTLink reqUser={user} game={game} playtype={playtype} /> highlighted{" "}
|
|
{subMessage}!
|
|
</span>
|
|
{mutedText && (
|
|
<>
|
|
<br />
|
|
<Muted>{mutedText}</Muted>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="timeline-content-timestamp">
|
|
{MillisToSince(data.scores[0].timeAchieved ?? 0)}
|
|
<br />
|
|
<span className="text-muted font-italic text-right">
|
|
{FormatTime(data.scores[0].timeAchieved ?? 0)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{show && (
|
|
<>
|
|
<Divider />
|
|
<ScoreTable
|
|
noTopDisplayStr
|
|
dataset={dataset}
|
|
game={game}
|
|
playtype={playtype}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GoalActivity({
|
|
data,
|
|
user,
|
|
shouldShowGame,
|
|
}: {
|
|
data: ClumpedActivityGoalAchievement;
|
|
user: UserDocument;
|
|
shouldShowGame: boolean;
|
|
}) {
|
|
const { game, playtype } = data.goals[0];
|
|
|
|
const prettyGame = shouldShowGame ? `${FormatGame(game, playtype)} ` : "";
|
|
|
|
const [show, setShow] = useState(false);
|
|
|
|
let subMessage;
|
|
let mutedText: string | null | undefined;
|
|
|
|
if (data.goals.length === 1) {
|
|
const goal0 = data.goals[0];
|
|
|
|
subMessage = `${goal0.__related.goal.name}${
|
|
shouldShowGame ? ` in ${FormatGame(game, playtype)}` : ""
|
|
}!`;
|
|
} else {
|
|
subMessage = `${data.goals.length} ${prettyGame}goals`;
|
|
|
|
mutedText = TruncateString(data.goals.map((e) => e.__related.goal.name).join(", "), 100);
|
|
}
|
|
|
|
return (
|
|
<div className="timeline-item timeline-hover my-4">
|
|
<div className="timeline-badge bg-warning"></div>
|
|
<div className="timeline-content">
|
|
<div className="timeline-content-inner" onClick={() => setShow(!show)}>
|
|
<div className="timeline-content-title">
|
|
<Icon
|
|
type={`chevron-${show ? "down" : "right"}`}
|
|
style={{
|
|
fontSize: "0.75rem",
|
|
}}
|
|
/>
|
|
<span style={{ fontSize: "1.15rem" }} className="ml-2">
|
|
<UGPTLink reqUser={user} game={game} playtype={playtype} /> achieved{" "}
|
|
{subMessage}!
|
|
</span>
|
|
{mutedText && (
|
|
<>
|
|
<br />
|
|
<Muted>{mutedText}</Muted>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="timeline-content-timestamp">
|
|
{MillisToSince(data.goals[0]?.timeAchieved ?? 0)}
|
|
<br />
|
|
<span className="text-muted font-italic text-right">
|
|
{FormatTime(data.goals[0]?.timeAchieved ?? 0)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{show && (
|
|
<>
|
|
<Divider />
|
|
<div className="pl-4">
|
|
{data.goals.map((e) => (
|
|
<InnerQuestSectionGoal
|
|
goal={e.__related.goal}
|
|
goalSubOverride={e}
|
|
key={e.goalID}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function QuestActivity({
|
|
data,
|
|
user,
|
|
shouldShowGame,
|
|
}: {
|
|
data: ClumpedActivityQuestAchievement;
|
|
user: UserDocument;
|
|
shouldShowGame: boolean;
|
|
}) {
|
|
const { game, playtype } = data.quest;
|
|
|
|
const prettyGame = shouldShowGame ? FormatGame(game, playtype) : "";
|
|
|
|
return (
|
|
<div className="timeline-item timeline-hover my-4">
|
|
<div className="timeline-badge bg-warning"></div>
|
|
<div className="timeline-content">
|
|
<div className="timeline-content-inner">
|
|
<div className="timeline-content-title">
|
|
<span style={{ fontSize: "1.15rem" }}>
|
|
<UGPTLink reqUser={user} game={game} playtype={playtype} /> completed
|
|
the{" "}
|
|
<Link
|
|
className="gentle-link"
|
|
to={`/games/${game}/${playtype}/quests/${data.quest.questID}`}
|
|
>
|
|
{data.quest.name}
|
|
</Link>{" "}
|
|
quest{prettyGame && ` in ${prettyGame}`}!
|
|
</span>
|
|
</div>
|
|
|
|
<div className="timeline-content-timestamp">
|
|
{MillisToSince(data.sub.timeAchieved ?? 0)}
|
|
<br />
|
|
<span className="text-muted font-italic text-right">
|
|
{FormatTime(data.sub.timeAchieved ?? 0)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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;
|
|
|
|
return (
|
|
<div className="timeline-item timeline-hover">
|
|
<div className={`timeline-badge bg-${data.highlight ? "warning" : "secondary"}`}></div>
|
|
<div className="timeline-content d-flex">
|
|
<div className="timeline-content-inner" onClick={() => setShow(!show)}>
|
|
<div className="timeline-content-title">
|
|
<Icon
|
|
type={`chevron-${show ? "down" : "right"}`}
|
|
style={{
|
|
fontSize: "0.75rem",
|
|
}}
|
|
/>
|
|
<span
|
|
className="ml-2"
|
|
style={{
|
|
fontWeight: isProbablyActive ? "bold" : undefined,
|
|
fontSize: isProbablyActive ? "1.2rem" : undefined,
|
|
}}
|
|
>
|
|
{/* 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." && (
|
|
<span className="text-muted">{data.desc}</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="timeline-content-timestamp">
|
|
{MillisToSince(data.timeStarted ?? 0)}
|
|
<br />
|
|
<span className="text-muted font-italic text-right">
|
|
{FormatTime(data.timeStarted ?? 0)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{show && <SessionShower sessionID={data.sessionID} />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SessionShower({ sessionID }: { sessionID: string }) {
|
|
const { data, error } = useApiQuery<SessionReturns>(`/sessions/${sessionID}`);
|
|
|
|
if (error) {
|
|
return <ApiError error={error} />;
|
|
}
|
|
|
|
if (!data) {
|
|
return <Loading />;
|
|
}
|
|
|
|
const scoreMap = CreateScoreIDMap(data.scores);
|
|
|
|
const gptConfig = GetGamePTConfig(data.session.game, data.session.playtype);
|
|
|
|
const raises = data.session.scoreInfo.filter((e) => {
|
|
const score = scoreMap.get(e.scoreID);
|
|
|
|
// shouldnt happen, but whatever
|
|
if (!score) {
|
|
return false;
|
|
}
|
|
|
|
if (e.isNewScore) {
|
|
return (
|
|
score.scoreData.lampIndex > gptConfig.lamps.indexOf(gptConfig.clearLamp) ||
|
|
score.scoreData.gradeIndex > gptConfig.grades.indexOf(gptConfig.clearGrade)
|
|
);
|
|
} else if (e.gradeDelta > 0) {
|
|
return score.scoreData.gradeIndex > gptConfig.grades.indexOf(gptConfig.clearGrade);
|
|
} else if (e.lampDelta > 0) {
|
|
return score.scoreData.lampIndex > gptConfig.lamps.indexOf(gptConfig.clearLamp);
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
if (raises.length === 0) {
|
|
return (
|
|
<Row className="mt-4">
|
|
<div className="d-flex w-100 justify-content-center flex-column">
|
|
<div className="mb-4">This session had no raises.</div>
|
|
<div>
|
|
<LinkButton
|
|
className="btn-outline-primary"
|
|
to={`/u/${data.user.username}/games/${data.session.game}/${data.session.playtype}/sessions/${sessionID}`}
|
|
>
|
|
View Full Session
|
|
</LinkButton>
|
|
</div>
|
|
</div>
|
|
</Row>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Row className="mt-4">
|
|
<SessionRaiseBreakdown sessionData={data} setScores={NO_OP} />
|
|
<Col xs={12}>
|
|
<Divider />
|
|
</Col>
|
|
<div className="d-flex w-100 justify-content-center">
|
|
<LinkButton
|
|
className="btn-outline-primary"
|
|
to={`/u/${data.user.username}/games/${data.session.game}/${data.session.playtype}/sessions/${sessionID}`}
|
|
>
|
|
View Full Session
|
|
</LinkButton>
|
|
</div>
|
|
</Row>
|
|
);
|
|
}
|
|
|
|
function ClassAchievementActivity({
|
|
data,
|
|
user,
|
|
shouldShowGame,
|
|
}: {
|
|
data: ClumpedActivityClassAchievement;
|
|
user: UserDocument;
|
|
shouldShowGame: boolean;
|
|
}) {
|
|
return (
|
|
<div className="timeline-item timeline-hover">
|
|
<div className="timeline-badge bg-success"></div>
|
|
<div className="timeline-content d-flex">
|
|
<div className="timeline-content-inner">
|
|
<div className="timeline-content-title">
|
|
<UGPTLink reqUser={user} game={data.game} playtype={data.playtype} />{" "}
|
|
achieved{" "}
|
|
<ClassBadge
|
|
classSet={data.classSet}
|
|
game={data.game}
|
|
playtype={data.playtype}
|
|
classValue={data.classValue}
|
|
/>
|
|
{shouldShowGame && ` in ${FormatGame(data.game, data.playtype)}`}!
|
|
{data.classOldValue !== null && (
|
|
<>
|
|
{" "}
|
|
(Raised from{" "}
|
|
<ClassBadge
|
|
classSet={data.classSet}
|
|
game={data.game}
|
|
playtype={data.playtype}
|
|
classValue={data.classOldValue}
|
|
/>
|
|
)
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="timeline-content-timestamp">
|
|
{MillisToSince(data.timeAchieved)}
|
|
<br />
|
|
<span className="text-muted font-italic text-right">
|
|
{FormatTime(data.timeAchieved)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UGPTLink({ reqUser, game, playtype }: UGPT) {
|
|
// currently
|
|
const { user } = useContext(UserContext);
|
|
|
|
return (
|
|
<Link
|
|
to={`/u/${reqUser.username}/games/${game}/${playtype}`}
|
|
className="gentle-link"
|
|
style={{
|
|
fontWeight: "bold",
|
|
}}
|
|
>
|
|
{user?.id === reqUser.id ? "You" : reqUser.username}
|
|
</Link>
|
|
);
|
|
}
|