@@ -188,247 +211,42 @@ export default function SpecificFolderPage({ reqUser, game }: Props) {
);
}
-/** Total `` count per timeline score row (index, difficulty, title, scores, timestamp). */
-function folderTimelineColumnCount(game: V3Game): integer {
- const headersLen = GPT_CLIENT_IMPLEMENTATIONS[game].scoreHeaders.length;
- return 5 + headersLen;
-}
-
-function TimelineView({ game, reqUser, folderSlug }: { folderSlug: string } & Props) {
- const gameConfig = GetGameConfig(game);
- const enumConfs = GetScoreEnumConfs(gameConfig);
-
- const [selectedEnum, setSelectedEnum] = useState(gameConfig.preferredDefaultEnum);
- const [enumConf, setEnumConf] = useState>(enumConfs[selectedEnum]!);
-
- const [value, setValue] = useState(enumConf.minimumRelevantValue);
-
- useEffect(() => {
- setValue(enumConfs[selectedEnum]!.minimumRelevantValue);
- setEnumConf(enumConfs[selectedEnum]!);
- }, [selectedEnum]);
-
- return (
- <>
-
-
- The timeline view shows the order in which you achieved something in a folder!
- You can choose the criteria up here.
-
-
- setSelectedEnum(e.target.value)}
- value={selectedEnum}
- >
- {Object.keys(enumConfs).map((e) => (
-
- ))}
-
- setValue(e.target.value)} value={value}>
- {enumConf.values
- .slice(enumConf.values.indexOf(enumConf.minimumRelevantValue))
- .map((e) => (
-
- ))}
-
-
-
-
-
- >
- );
-}
-
-function TimelineMain({
- reqUser,
+function FolderNormalView({
+ data,
+ folderDataset,
+ folderTableEnumPreset,
game,
- folderSlug,
- enumMetric: enumMetric,
- value,
+ reqUser,
}: {
- enumMetric: string;
- folderSlug: string;
- value: string;
+ data: UGPTFolderReturns;
+ folderDataset: FolderDataset;
+ folderTableEnumPreset: FolderEnumBreakdownTablePreset | null;
} & Props) {
- const { data, error } = useApiQuery<{
- charts: ChartDocument[];
- scores: ScoreDocument[];
- songs: SongDocument[];
- }>(
- `/users/${
- reqUser.id
- }/games/${game}/folders/${folderSlug}/timeline?criteriaValue=${encodeURIComponent(
- value,
- )}&criteriaType=${encodeURIComponent(enumMetric)}`,
+ const gameConfig = useMemo(() => GetGameConfig(game), [game]);
+
+ const evolutionFolderScope = useMemo(
+ () => ({ folder: data.folder, kind: "folder" as const }),
+ [data.folder],
);
- if (error) {
- return ;
- }
-
- if (!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 columnCount = folderTimelineColumnCount(game);
- const folderTimelineScoreColVar = GPT_CLIENT_IMPLEMENTATIONS[game].scoreHeaders.length + 1;
-
- const timelineRows = [];
-
- 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;
- timelineRows.push(
-
- {FormatDate(scoreData.timeAchieved)}
- ,
- );
- }
- } else if (!hasHitNulls) {
- timelineRows.push(
-
- Unknown Time
- ,
- );
- hasHitNulls = true;
- }
-
- timelineRows.push(
- ,
- );
- index++;
- }
-
- const emptyTimelineRow =
- timelineRows.length === 0 ? (
-
- |
- No scores match this folder and criteria yet.
- |
-
- ) : null;
-
return (
<>
-
- Total Progress
-
- {data.scores.length}
-
- /{data.charts.length}
-
-
-
+
-
-
-
- {emptyTimelineRow ?? timelineRows}
-
-
-
-
-
- Total Progress
-
- {data.scores.length}
-
- /{data.charts.length}
-
-
-
+
>
);
}
-function TimelineDivider({ children, columnCount }: { children: string; columnCount: integer }) {
- return (
-
- |
- {children}
- |
-
- );
-}
-
-function TimelineElement({
- scoreData,
- index,
-}: {
- index: integer;
- scoreData: {
- __related: {
- chart: ChartDocument;
- song: SongDocument;
- };
- } & ScoreDocument;
-}) {
- return (
-
- |
- #{index}
- {Date.now() - scoreData.timeAdded < ONE_DAY && (
- NEW!
- )}
- |
-
-
-
-
-
- );
-}
-
// here be demons
// i don't remember how this code works
// and i was almost certainly drinking heavily when i wrote it
diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/TableEvolutionReplay.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/TableEvolutionReplay.tsx
new file mode 100644
index 000000000..dd97ab243
--- /dev/null
+++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/TableEvolutionReplay.tsx
@@ -0,0 +1,749 @@
+import type { FolderStatsInfo, UGPTEvolutionReplayReturns } from "#types/api-returns";
+
+import ApiError from "#components/util/ApiError";
+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 SelectButton from "#components/util/SelectButton";
+import { useBucket } from "#components/util/useBucket";
+import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations";
+import { ChangeOpacity } from "#util/color-opacity";
+import { CreateChartIDMap, CreateSongMap } from "#util/data";
+import { UppercaseFirst } from "#util/misc";
+import { FormatDate, MillisToSince } from "#util/time";
+import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import Button from "react-bootstrap/Button";
+import Collapse from "react-bootstrap/Collapse";
+import Dropdown from "react-bootstrap/Dropdown";
+import {
+ type FolderDocument,
+ type GameConfig,
+ GetScoreMetrics,
+ type TableDocument,
+ type UserDocument,
+ type V3Game,
+} from "tachi-common";
+
+import EvolutionMilestoneFeedRow from "./EvolutionMilestoneFeedRow";
+import EvolutionTimelineStrip from "./EvolutionTimelineStrip";
+import FolderEnumDistributionBreakdown from "./FolderEnumDistributionBreakdown";
+import FolderEnumProgressBar from "./FolderEnumProgressBar";
+import folderTableStyles from "./FolderTablePage.module.scss";
+import {
+ buildEvolutionReplayFolderStats,
+ evoEventTimeMs,
+ tableFolderSlugsDisplayOrder,
+} from "./folderTableShared";
+
+export type EvolutionReplayScope =
+ | { folder: FolderDocument; kind: "folder" }
+ | { kind: "table"; table: TableDocument };
+
+const MILESTONE_FEED_MAX_ROWS = 20;
+
+/** Wall-clock multipliers vs the baseline tween (long spans aim for ~≤28s at 1×). */
+const PLAYBACK_SPEED_OPTIONS = [0.25, 0.5, 1, 2, 4] as const;
+
+export default function TableEvolutionReplay({
+ controlledEnumMetric,
+ game,
+ gameConfig,
+ reqUser,
+ scope,
+}: {
+ controlledEnumMetric?: readonly [
+ metric: string,
+ setMetric: React.Dispatch>,
+ ];
+ game: V3Game;
+ gameConfig: GameConfig;
+ reqUser: UserDocument;
+ scope: EvolutionReplayScope;
+}) {
+ const externallyControlled = controlledEnumMetric !== undefined;
+ const replayScopeKey = scope.kind === "folder" ? scope.folder.folderID : scope.table.tableID;
+
+ const bucket = useBucket(game);
+ const [internalEnumMetric, setInternalEnumMetric] = useState(bucket);
+
+ useEffect(() => {
+ if (externallyControlled) {
+ return;
+ }
+
+ setInternalEnumMetric(bucket);
+ }, [bucket, externallyControlled, game, replayScopeKey]);
+
+ const enumMetric = externallyControlled ? controlledEnumMetric[0] : internalEnumMetric;
+ const setEnumMetric = externallyControlled ? controlledEnumMetric[1] : setInternalEnumMetric;
+
+ const enumMetricChoices = useMemo(() => GetScoreMetrics(gameConfig, "ENUM"), [gameConfig]);
+
+ const [open, setOpen] = useState(false);
+ const [scrubTimeMs, setScrubTimeMs] = useState(0);
+ const [playing, setPlaying] = useState(false);
+ const [playbackSpeed, setPlaybackSpeed] = useState<(typeof PLAYBACK_SPEED_OPTIONS)[number]>(1);
+
+ /** Wall clock anchor for timeline max (right edge = "now"); ticks while replay is expanded. */
+ const [replayNowMs, setReplayNowMs] = useState(() => Date.now());
+ /** Bumps whenever the replay panel expands so we snap scrub to today's edge without reacting to replayNowMs ticking. */
+ const [replayOpenNonce, setReplayOpenNonce] = useState(0);
+
+ useLayoutEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ setReplayNowMs(Date.now());
+ setReplayOpenNonce((n) => n + 1);
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) {
+ return undefined;
+ }
+
+ const id = window.setInterval(() => {
+ setReplayNowMs(Date.now());
+ }, 60_000);
+
+ return () => {
+ window.clearInterval(id);
+ };
+ }, [open]);
+
+ const evolutionUrl =
+ scope.kind === "table"
+ ? `/users/${reqUser.id}/games/${game}/tables/${scope.table.tableID}/evolution`
+ : `/users/${reqUser.id}/games/${game}/folders/${scope.folder.slug}/evolution`;
+
+ const replayDomId = scope.kind === "table" ? scope.table.tableID : scope.folder.folderID;
+
+ const folderSlugOrder = useMemo(
+ () =>
+ scope.kind === "table"
+ ? tableFolderSlugsDisplayOrder(scope.table)
+ : [scope.folder.slug],
+ [scope],
+ );
+
+ const scrubRef = useRef(0);
+ const foldersReplayPanelRef = useRef(null);
+ const [milestoneFeedFrameHeightPx, setMilestoneFeedFrameHeightPx] = useState(0);
+
+ useLayoutEffect(() => {
+ scrubRef.current = scrubTimeMs;
+ }, [scrubTimeMs]);
+
+ const {
+ data: evo,
+ error: evoError,
+ isLoading: evolutionLoading,
+ } = useApiQuery(evolutionUrl, undefined, undefined, !open);
+
+ const events = useMemo(() => evo?.events ?? [], [evo?.events]);
+
+ const workingEvents = useMemo(
+ () => events.filter((evItem) => evItem.metric === enumMetric),
+ [events, enumMetric],
+ );
+
+ /** Chronologically first milestone in the evolution payload (any enum metric ≈ earliest table scores we model). */
+ const tableEvolutionEarliestMs = useMemo(() => {
+ let earliest: number | undefined;
+
+ for (const evItem of events) {
+ const t = evoEventTimeMs(evItem);
+ if (!Number.isFinite(t)) {
+ continue;
+ }
+ earliest = earliest === undefined ? t : Math.min(earliest, t);
+ }
+
+ return earliest;
+ }, [events]);
+
+ const { minAxis, maxAxis } = useMemo(() => {
+ const nowMs = replayNowMs;
+
+ if (workingEvents.length === 0) {
+ return { minAxis: 0, maxAxis: 1 };
+ }
+
+ const times = workingEvents.map(evoEventTimeMs);
+ const firstT = Math.min(...times);
+ const lastT = Math.max(...times);
+
+ if (!(Number.isFinite(firstT) && Number.isFinite(lastT))) {
+ const anchorMs =
+ tableEvolutionEarliestMs !== undefined
+ ? tableEvolutionEarliestMs
+ : nowMs - 3_600_000;
+ return {
+ minAxis: anchorMs,
+ maxAxis: nowMs,
+ };
+ }
+
+ const axisLeftMs =
+ tableEvolutionEarliestMs !== undefined ? tableEvolutionEarliestMs : firstT;
+
+ return {
+ // Timeline starts at the earliest milestone in evolution for this table (any metric).
+ minAxis: axisLeftMs,
+
+ maxAxis: Math.max(nowMs, lastT),
+ };
+ }, [replayNowMs, tableEvolutionEarliestMs, workingEvents]);
+
+ const axisSpan = Math.max(maxAxis - minAxis, 1);
+
+ const minAxisRef = useRef(minAxis);
+ minAxisRef.current = minAxis;
+ const maxAxisRef = useRef(maxAxis);
+ maxAxisRef.current = maxAxis;
+ const axisSpanRef = useRef(axisSpan);
+ axisSpanRef.current = axisSpan;
+
+ const appliedEvents = useMemo(() => {
+ if (workingEvents.length === 0) {
+ return [];
+ }
+
+ return workingEvents.filter((e) => evoEventTimeMs(e) <= scrubTimeMs);
+ }, [scrubTimeMs, workingEvents]);
+
+ useEffect(() => {
+ if (!open || !evo) {
+ setPlaying(false);
+ return;
+ }
+
+ if (workingEvents.length === 0) {
+ return;
+ }
+
+ setScrubTimeMs(maxAxisRef.current);
+ setPlaying(false);
+ }, [evo, enumMetric, open, replayOpenNonce, workingEvents.length]);
+
+ useEffect(() => {
+ if (!playing || workingEvents.length === 0) {
+ return undefined;
+ }
+
+ let cancelled = false;
+ const rawFrom = scrubRef.current;
+ const maxStart = maxAxisRef.current;
+ const minStart = minAxisRef.current;
+ const spanStart = axisSpanRef.current;
+
+ const from = Math.min(Math.max(rawFrom, minStart), maxStart);
+
+ if (from >= maxStart) {
+ setPlaying(false);
+ return undefined;
+ }
+
+ const remainder = Math.max(maxStart - from, 1);
+ const timelineSpan = Math.max(spanStart, 1);
+ const playWallMsBase = Math.max(600, Math.min(28_000, (remainder / timelineSpan) * 28_000));
+ // Playback presets were uniformly too brisk; ×2 duration halves effective speed at each multiplier.
+ const playWallMs = (2 * playWallMsBase) / playbackSpeed;
+ const wallT0 = performance.now();
+
+ function tickFrame(nowWall: number) {
+ if (cancelled) {
+ return;
+ }
+
+ const p = Math.min(1, (nowWall - wallT0) / playWallMs);
+ const next = from + remainder * p;
+
+ setScrubTimeMs(Math.min(Math.max(next, minAxisRef.current), maxAxisRef.current));
+
+ if (p >= 1) {
+ queueMicrotask(() => {
+ setPlaying(false);
+ });
+ return;
+ }
+
+ requestAnimationFrame(tickFrame);
+ }
+
+ requestAnimationFrame(tickFrame);
+
+ return () => {
+ cancelled = true;
+ };
+ }, [playbackSpeed, playing, workingEvents.length]);
+
+ const rangeStep = useMemo(() => {
+ const coarse = axisSpan / 1500;
+ return Math.min(axisSpan, Math.max(1000, Math.floor(coarse)));
+ }, [axisSpan]);
+
+ const folderBySlug = useMemo(() => {
+ const m = new Map();
+ for (const f of evo?.folders ?? []) {
+ m.set(f.slug, f);
+ }
+ return m;
+ }, [evo?.folders]);
+
+ const chartMap = useMemo(
+ () => (evo?.charts ? CreateChartIDMap(evo.charts) : new Map()),
+ [evo?.charts],
+ );
+ const songMap = useMemo(
+ () => (evo?.songs ? CreateSongMap(evo.songs) : new Map()),
+ [evo?.songs],
+ );
+
+ const folderChartIDs = useMemo(() => evo?.folderChartIDs ?? {}, [evo?.folderChartIDs]);
+ const replayRows = useMemo(() => {
+ if (!open || !evo) {
+ return [] as Array<{ folder: FolderDocument; stats: FolderStatsInfo }>;
+ }
+
+ const applied = appliedEvents;
+ const rows: Array<{ folder: FolderDocument; stats: FolderStatsInfo }> = [];
+
+ for (const slug of folderSlugOrder) {
+ const folder = folderBySlug.get(slug);
+
+ if (!folder) {
+ continue;
+ }
+
+ const stats = buildEvolutionReplayFolderStats({
+ enumMetric,
+ eventsPrefix: applied,
+ folderChartIDs,
+ folderSlug: slug,
+ gameConfig,
+ });
+
+ rows.push({ folder, stats });
+ }
+
+ return rows;
+ }, [
+ appliedEvents,
+ enumMetric,
+ evo,
+ folderBySlug,
+ folderChartIDs,
+ folderSlugOrder,
+ gameConfig,
+ open,
+ ]);
+
+ useLayoutEffect(() => {
+ if (!open || workingEvents.length === 0) {
+ setMilestoneFeedFrameHeightPx(0);
+ return;
+ }
+
+ const el = foldersReplayPanelRef.current;
+ if (!el) {
+ return;
+ }
+
+ let rafId = 0;
+
+ const syncHeight = () => {
+ cancelAnimationFrame(rafId);
+ rafId = requestAnimationFrame(() => {
+ const h = el.getBoundingClientRect().height;
+ const next = Boolean(h) && Number.isFinite(h) ? Math.round(h * 1000) / 1000 : 0;
+ setMilestoneFeedFrameHeightPx((prev) => (prev === next ? prev : next));
+ });
+ };
+
+ syncHeight();
+
+ const ro = new ResizeObserver(syncHeight);
+ ro.observe(el);
+ window.addEventListener("resize", syncHeight);
+
+ return () => {
+ cancelAnimationFrame(rafId);
+ window.removeEventListener("resize", syncHeight);
+ ro.disconnect();
+ };
+ }, [
+ appliedEvents.length,
+ enumMetric,
+ open,
+ replayRows.length,
+ scrubTimeMs,
+ replayDomId,
+ workingEvents.length,
+ ]);
+
+ const allEnumColours = useMemo(
+ () =>
+ GPT_CLIENT_IMPLEMENTATIONS[game].enumColours as
+ | Record>
+ | undefined,
+ [game],
+ );
+
+ const enumColours = useMemo(() => allEnumColours?.[enumMetric], [allEnumColours, enumMetric]);
+
+ const feedRows = useMemo(() => [...appliedEvents].reverse(), [appliedEvents]);
+ const visibleFeedRows = useMemo(() => feedRows.slice(0, MILESTONE_FEED_MAX_ROWS), [feedRows]);
+ const _milestoneFeedTruncated = feedRows.length > MILESTONE_FEED_MAX_ROWS;
+
+ const onPlayToggle = () => {
+ if (workingEvents.length === 0) {
+ return;
+ }
+
+ if (playing) {
+ setPlaying(false);
+ return;
+ }
+
+ if (scrubTimeMs >= maxAxis) {
+ setScrubTimeMs(minAxis);
+ }
+
+ setPlaying(true);
+ };
+
+ const feedRowAccent = (metric: string, valueLabel: string): React.CSSProperties => {
+ const raw = allEnumColours?.[metric]?.[valueLabel];
+ const borderLeftColor = raw ?? "var(--bs-secondary)";
+ let backgroundColor: string | undefined;
+ if (raw?.startsWith("#") || raw?.startsWith("rgb")) {
+ try {
+ backgroundColor = ChangeOpacity(raw, 0.12);
+ } catch {
+ backgroundColor = undefined;
+ }
+ }
+ return {
+ borderLeftWidth: 4,
+ borderLeftStyle: "solid",
+ borderLeftColor,
+ ...(backgroundColor ? { backgroundColor } : {}),
+ };
+ };
+
+ const rangeId = `evolution-replay-scrub-${replayDomId}`;
+ const triggerLabelId = `evolution-replay-trigger-${replayDomId}`;
+ const collapseId = `evolution-replay-collapse-${replayDomId}`;
+
+ return (
+
+
+
+
+
+
+
+ {enumMetricChoices.length > 1 ? (
+
+
+ {enumMetricChoices.map((metric) => (
+
+ {" "}
+ {UppercaseFirst(metric)}
+
+ ))}
+
+
+ ) : null}
+ {evoError ? : null}
+
+ {open && evolutionLoading ? : null}
+
+ {evo && !evolutionLoading && !evoError ? (
+ <>
+ {events.length === 0 ? (
+
+ {scope.kind === "table"
+ ? "Nothing, yet for this table."
+ : "Nothing, yet for this folder."}
+
+ ) : (
+ <>
+
+
+
+
+
+
+ Current replay time
+
+
+
+ {FormatDate(scrubTimeMs)}
+
+
+
+ {MillisToSince(scrubTimeMs)}
+
+
+
+
+
+
+
+
+
+
+ {PLAYBACK_SPEED_OPTIONS.map((rate) => (
+
+ setPlaybackSpeed(rate)
+ }
+ type="button"
+ >
+ {rate}×
+ {rate === 1 ? (
+
+ (normal)
+
+ ) : null}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {scope.kind === "table"
+ ? "Folders at"
+ : "Folder at"}{" "}
+
+ {FormatDate(scrubTimeMs)}
+
+
+
+ {replayRows.map(({ folder, stats }) => (
+
+
+
+ {folder.title}
+
+
+
+
+
+
+ {stats.chartCount}
+
+
+
+ {scope.kind === "folder" ? (
+
+ ) : null}
+
+ ))}
+
+
+
+
+ Scores
+
+ 0
+ ? {
+ height: milestoneFeedFrameHeightPx,
+ }
+ : {}),
+ }}
+ >
+ {visibleFeedRows.length === 0 ? (
+
+ Nothing before this position.
+
+ ) : (
+ visibleFeedRows.map((evRow) => {
+ const chart = chartMap.get(evRow.chartID);
+ const song = chart
+ ? songMap.get(chart.song.id)
+ : undefined;
+
+ const chipFill =
+ allEnumColours?.[evRow.metric]?.[
+ evRow.value
+ ];
+ let chipBg: string | undefined;
+ if (
+ chipFill &&
+ (chipFill.startsWith("#") ||
+ chipFill.startsWith("rgb"))
+ ) {
+ try {
+ chipBg = ChangeOpacity(
+ chipFill,
+ 0.2,
+ );
+ } catch {
+ chipBg = undefined;
+ }
+ }
+
+ return (
+
+ );
+ })
+ )}
+
+
+
+ >
+ )}
+ >
+ ) : null}
+
+
+
+ );
+}
diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/TableFolderList.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/TableFolderList.tsx
new file mode 100644
index 000000000..d3694c4bb
--- /dev/null
+++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/TableFolderList.tsx
@@ -0,0 +1,134 @@
+import Icon from "#components/util/Icon";
+import { UserContext } from "#context/UserContext";
+import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations";
+import { APIFetchV1 } from "#util/api";
+import React, { useContext, useMemo } from "react";
+import { Link } from "react-router-dom";
+import {
+ GetGameConfig,
+ type TableDocument,
+} from "tachi-common";
+
+import FolderEnumProgressBar from "./FolderEnumProgressBar";
+import folderTableStyles from "./FolderTablePage.module.scss";
+import {
+ type FolderTableScopedProps,
+ tableFolderSlugsDisplayOrder,
+ type UGPTFolderStats,
+} from "./folderTableShared";
+
+export default function TableFolderList({
+ dataMap,
+ enumMetric,
+ game,
+ highlightFolderSlug,
+ highlightRevealKey = 0,
+ onFolderRowNavigate,
+ reqUser,
+ table,
+}: {
+ dataMap: Map;
+ enumMetric: string;
+ highlightFolderSlug?: string;
+ highlightRevealKey?: number;
+ onFolderRowNavigate?: () => void;
+ table: TableDocument;
+} & FolderTableScopedProps) {
+ const gameConfig = useMemo(() => GetGameConfig(game), [game]);
+
+ const enumColours = useMemo(
+ () =>
+ (
+ GPT_CLIENT_IMPLEMENTATIONS[game].enumColours as
+ | Record>
+ | undefined
+ )?.[enumMetric],
+ [enumMetric, game],
+ );
+
+ const dataset = useMemo(() => {
+ const arr = [];
+ for (const folder of tableFolderSlugsDisplayOrder(table)) {
+ const data = dataMap.get(folder);
+
+ if (!data) {
+ continue;
+ }
+
+ arr.push(data);
+ }
+
+ return arr;
+ }, [dataMap, table]);
+
+ const { user } = useContext(UserContext);
+
+ return (
+
+ {dataset.length === 0 ? (
+ No folders.
+ ) : (
+ dataset.map((data) => {
+ const isFolderOpen = highlightFolderSlug === data.folder.slug;
+ const linkKey =
+ isFolderOpen && highlightRevealKey > 0
+ ? `${data.folder.slug}--hilite--${highlightRevealKey}`
+ : data.folder.slug;
+
+ return (
+ {
+ if (user?.id === reqUser.id) {
+ APIFetchV1(
+ `/users/${reqUser.id}/games/${game}/folders/${data.folder.slug}/viewed`,
+ {
+ method: "POST",
+ },
+ );
+ }
+
+ onFolderRowNavigate?.();
+ }}
+ to={`/u/${reqUser.username}/games/${game}/folders/${data.folder.slug}`}
+ >
+
+
+ {data.folder.title}
+
+
+
+
+
+
+ {data.stats.chartCount}
+
+
+
+
+
+ );
+ })
+ )}
+
+ );
+}
diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/TableFolderViewer.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/TableFolderViewer.tsx
new file mode 100644
index 000000000..26067b137
--- /dev/null
+++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/TableFolderViewer.tsx
@@ -0,0 +1,97 @@
+import type { UGPTTableReturns } from "#types/api-returns";
+
+import ApiError from "#components/util/ApiError";
+import Divider from "#components/util/Divider";
+import Loading from "#components/util/Loading";
+import useApiQuery from "#components/util/query/useApiQuery";
+import { useBucket } from "#components/util/useBucket";
+import React, { useEffect, useMemo, useState } from "react";
+import { GetGameConfig, type TableDocument } from "tachi-common";
+
+import type { FolderTableScopedProps, UGPTFolderStats } from "./folderTableShared";
+
+import TableEvolutionReplay from "./TableEvolutionReplay";
+import TableFolderList from "./TableFolderList";
+
+export default function TableFolderViewer({
+ game,
+ highlightFolderSlug,
+ highlightRevealKey = 0,
+ onFolderRowNavigate,
+ reqUser,
+ table,
+}: {
+ highlightFolderSlug?: string;
+ highlightRevealKey?: number;
+ onFolderRowNavigate?: () => void;
+ table: TableDocument;
+} & FolderTableScopedProps) {
+ const { data, error } = useApiQuery(
+ `/users/${reqUser.id}/games/${game}/tables/${table.tableID}`,
+ );
+
+ const bucket = useBucket(game);
+ const [enumMetric, setEnumMetric] = useState(bucket);
+
+ useEffect(() => {
+ setEnumMetric(bucket);
+ }, [bucket, table.tableID]);
+
+ const [dataMap, setDataMap] = useState |