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 5cc282f0e..d21180d75 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
@@ -11,28 +11,36 @@ import Muted from "components/util/Muted";
import useApiQuery from "components/util/query/useApiQuery";
import SelectButton from "components/util/SelectButton";
import { useFormik } from "formik";
-import React, { useMemo, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
import { Col, Form, Row } from "react-bootstrap";
import { Link, useParams } from "react-router-dom";
import {
+ ChartDocument,
ChartTierlistInfo,
+ FormatDifficulty,
FormatDifficultyShort,
Game,
GetGamePTConfig,
GPTTierlists,
IDStrings,
+ integer,
PublicUserDocument,
+ ScoreDocument,
+ SongDocument,
} from "tachi-common";
import { UGPTFolderReturns } from "types/api-returns";
+import { JustChildren } from "types/react";
import { FolderDataset } from "types/tables";
import { Playtype } from "types/tachi";
import { DEFAULT_BAR_PROPS } from "util/charts";
import { ChangeOpacity } from "util/color-opacity";
+import { ONE_DAY } from "util/constants/time";
import { CreateChartIDMap, CreateChartLink, CreateSongMap } from "util/data";
import { ComposeExpFn, ComposeInverseExpFn, IsNullish, NO_OP } from "util/misc";
import { GetGradeChartExpScale } from "util/scales";
import { NumericSOV, StrSOV } from "util/sorts";
import { GetScaleAchievedFn } from "util/tierlist";
+import { FormatDate, FormatDuration, FormatTime } from "util/time";
interface Props {
reqUser: PublicUserDocument;
@@ -47,7 +55,7 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${folderID}`
);
- const [mode, setMode] = useState<"normal" | "ladder">("normal");
+ const [mode, setMode] = useState<"normal" | "ladder" | "timeline">("normal");
const gptConfig = GetGamePTConfig(game, playtype);
@@ -122,6 +130,10 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
Tierlist View
)}
+
+
+ Timeline View
+
@@ -136,7 +148,7 @@ export default function SpecificFolderPage({ reqUser, game, playtype }: Props) {
reqUser={reqUser}
indexCol={false}
/>
- ) : (
+ ) : mode === "ladder" ? (
+ ) : (
+
)}
);
}
+function TimelineView({ game, playtype, reqUser, folderID }: Props & { folderID: string }) {
+ const gptConfig = GetGamePTConfig(game, playtype);
+ const [type, setType] = useState<"lamp" | "grade">(gptConfig.scoreBucket);
+ const [value, setValue] = useState(
+ type === "grade" ? gptConfig.grades.length - 1 : gptConfig.lamps.length - 1
+ );
+
+ useEffect(() => {
+ setValue(type === "grade" ? gptConfig.grades.length - 1 : gptConfig.lamps.length - 1);
+ }, [type]);
+
+ return (
+ <>
+
+
+
+
+ The timeline view shows the order in which you achieved something in a
+ folder! You can choose the criteria up here.
+
+
+
+
+
setType(e.target.value as "grade" | "lamp")}
+ >
+
+
+
+
+
+
setValue(Number(e.target.value))}
+ >
+ {type === "grade"
+ ? gptConfig.grades.map((e, i) => (
+
+ ))
+ : gptConfig.lamps.map((e, i) => (
+
+ ))}
+
+
+
+
+
+
+ >
+ );
+}
+
+function TimelineMain({
+ reqUser,
+ game,
+ playtype,
+ folderID,
+ type,
+ value,
+}: Props & {
+ folderID: string;
+ type: "grade" | "lamp";
+ value: integer;
+}) {
+ const { data, isLoading, error } = useApiQuery<{
+ scores: ScoreDocument[];
+ songs: SongDocument[];
+ charts: ChartDocument[];
+ }>(
+ `/users/${reqUser.id}/games/${game}/${playtype}/folders/${folderID}/timeline?criteriaValue=${value}&criteriaType=${type}`
+ );
+
+ if (error) {
+ return ;
+ }
+
+ if (isLoading || !data) {
+ return ;
+ }
+
+ const scoreDataset = [];
+
+ const songMap = CreateSongMap(data.songs);
+ const chartMap = CreateChartIDMap(data.charts);
+
+ for (const score of data.scores) {
+ scoreDataset.push({
+ ...score,
+ __related: {
+ song: songMap.get(score.songID)!,
+ chart: chartMap.get(score.chartID)!,
+ },
+ });
+ }
+
+ scoreDataset.sort(NumericSOV(x => x.timeAchieved ?? Infinity));
+
+ const elements = [];
+
+ let lastDay = 0;
+ let index = 1;
+ let hasHitNulls = false;
+
+ for (const scoreData of scoreDataset) {
+ if (scoreData.timeAchieved !== null) {
+ // Insane hack to floor a date to the beginning of that
+ // day.
+ const dayNum = new Date(scoreData.timeAchieved).setHours(0, 0, 0, 0);
+
+ if (!lastDay || lastDay !== dayNum) {
+ lastDay = dayNum;
+ elements.push(
+ {FormatDate(scoreData.timeAchieved)}
+ );
+ }
+ } else if (!hasHitNulls) {
+ elements.push(Unknown Time);
+ hasHitNulls = true;
+ }
+
+ elements.push(
+
+ );
+ index++;
+ }
+
+ return (
+ <>
+
+
+
+
Total Progress
+
+ {data.scores.length}
+
+ /{data.charts.length}
+
+
+
+ >
+ );
+}
+
+function TimelineDivider({ children }: { children: string }) {
+ return (
+
+
{children}
+
+ );
+}
+
+function TimelineElement({
+ scoreData,
+ index,
+}: {
+ index: integer;
+ scoreData: ScoreDocument & {
+ __related: {
+ song: SongDocument;
+ chart: ChartDocument;
+ };
+ };
+}) {
+ return (
+
+
+
+
+ #{index}:{" "}
+
+ {scoreData.__related.song.title}{" "}
+ {FormatDifficulty(scoreData.__related.chart, scoreData.game)}
+
+ {Date.now() - scoreData.timeAdded < ONE_DAY && (
+
+ NEW!
+
+ )}
+
+
+ {scoreData.timeAchieved === null
+ ? "Unknown Time"
+ : FormatTime(scoreData.timeAchieved)}
+
+
+
+ );
+}
+
type InfoProps = Props & {
folderDataset: FolderDataset;
data: UGPTFolderReturns;