From e15f52bbe7a598aeaa32e88c59947642d3e40d7b Mon Sep 17 00:00:00 2001 From: zk Date: Mon, 11 May 2026 19:07:09 +0000 Subject: [PATCH] feat: masterpiece --- .../EvolutionMilestoneFeedRow.module.scss | 22 + .../folders/EvolutionMilestoneFeedRow.tsx | 110 +++ .../EvolutionTimelineStrip.module.scss | 10 + .../folders/EvolutionTimelineStrip.tsx | 349 ++++++++ ...olderEnumDistributionBreakdown.module.scss | 33 + .../FolderEnumDistributionBreakdown.tsx | 267 +++++++ .../folders/FolderEnumProgressBar.module.scss | 64 ++ .../folders/FolderEnumProgressBar.tsx | 179 +++++ .../folders/FolderTablePage.module.scss | 112 +-- .../_playtype/folders/FolderTablePage.tsx | 486 +++--------- .../_playtype/folders/FoldersMainPage.tsx | 16 +- .../_playtype/folders/SpecificFolderPage.tsx | 318 ++------ .../folders/TableEvolutionReplay.tsx | 749 ++++++++++++++++++ .../_playtype/folders/TableFolderList.tsx | 134 ++++ .../_playtype/folders/TableFolderViewer.tsx | 97 +++ .../_playtype/folders/folderTableShared.ts | 75 ++ .../game/folder/FolderInfoHeader.tsx | 141 ++-- .../components/layout/misc/QuickTooltip.tsx | 23 +- .../tables/components/TachiTable.module.scss | 70 ++ .../tables/components/TachiTable.tsx | 91 ++- .../components/tables/folders/FolderTable.tsx | 51 +- typescript/client/src/types/api-returns.ts | 38 + typescript/client/src/util/time.ts | 5 + .../server/src/lib/folders/table-evolution.ts | 200 +++++ .../server/src/server/router/api/v1/spec.ts | 48 ++ .../folders/_folderSlug/router.test.ts | 80 ++ .../_playtype/folders/_folderSlug/router.ts | 28 + .../_game/_playtype/tables/router.test.ts | 79 ++ .../games/_game/_playtype/tables/router.ts | 29 + typescript/server/src/utils/queries/scores.ts | 26 + 30 files changed, 3135 insertions(+), 795 deletions(-) create mode 100644 typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionMilestoneFeedRow.module.scss create mode 100644 typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionMilestoneFeedRow.tsx create mode 100644 typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionTimelineStrip.module.scss create mode 100644 typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionTimelineStrip.tsx create mode 100644 typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumDistributionBreakdown.module.scss create mode 100644 typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumDistributionBreakdown.tsx create mode 100644 typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumProgressBar.module.scss create mode 100644 typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumProgressBar.tsx create mode 100644 typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/TableEvolutionReplay.tsx create mode 100644 typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/TableFolderList.tsx create mode 100644 typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/TableFolderViewer.tsx create mode 100644 typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/folderTableShared.ts create mode 100644 typescript/client/src/components/tables/components/TachiTable.module.scss create mode 100644 typescript/server/src/lib/folders/table-evolution.ts diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionMilestoneFeedRow.module.scss b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionMilestoneFeedRow.module.scss new file mode 100644 index 000000000..3fb826f71 --- /dev/null +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionMilestoneFeedRow.module.scss @@ -0,0 +1,22 @@ +.feedRowLink { + color: inherit; + display: block; + text-decoration: none; + transition: + box-shadow 0.12s ease, + transform 0.12s ease; + + &:hover { + box-shadow: + 0 0 0 1px var(--bs-primary), + 0 0.35rem 1rem rgb(0 0 0 / 0.12); + color: inherit; + text-decoration: none; + transform: translateY(-2px); + } + + &:focus-visible { + outline: 2px solid var(--bs-primary); + outline-offset: 2px; + } +} diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionMilestoneFeedRow.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionMilestoneFeedRow.tsx new file mode 100644 index 000000000..6ec4653e1 --- /dev/null +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionMilestoneFeedRow.tsx @@ -0,0 +1,110 @@ +import type { TableEvolutionEventAPI } from "#types/api-returns"; + +import Muted from "#components/util/Muted"; +import { CreateChartLink } from "#util/data"; +import { UppercaseFirst } from "#util/misc"; +import { FormatTime, MillisToSince } from "#util/time"; +import React, { type CSSProperties } from "react"; +import { Link } from "react-router-dom"; +import { + type ChartDocument, + FormatDifficultyShort, + type SongDocument, + type V3Game, +} from "tachi-common"; + +import rowStyles from "./EvolutionMilestoneFeedRow.module.scss"; + +type EvolutionFeedEvent = TableEvolutionEventAPI; + +const feedRowBaseClass = + "border border-secondary border-opacity-25 flex-shrink-0 position-relative rounded-3 shadow-sm"; + +export default function EvolutionMilestoneFeedRow({ + evRow, + chart, + game, + song, + articleStyle, + chipFill, + chipBg, +}: { + articleStyle: CSSProperties; + chart?: ChartDocument; + chipBg: string | undefined; + chipFill: string | undefined; + evRow: EvolutionFeedEvent; + game: V3Game; + song?: SongDocument; +}) { + const timeMs = evRow.timeAchieved ?? evRow.timeAdded; + const chartHref = chart ? CreateChartLink(chart) : `/games/${game}/charts/${evRow.chartID}`; + + const body = ( +
+
+
+
+ {song && chart ? ( + <> + {song.title} + + ({FormatDifficultyShort(chart)}) + + + ) : chart ? ( + <> + Unknown chart + + ({FormatDifficultyShort(chart)}) + + + ) : ( + Unknown chart + )} +
+
+ + {UppercaseFirst(evRow.metric)} + + + {evRow.value} + +
+
+
+
+
+ {MillisToSince(timeMs)} +
+ +
+ {FormatTime(timeMs)} +
+
+
+
+
+ ); + + return ( + + {body} + + ); +} diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionTimelineStrip.module.scss b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionTimelineStrip.module.scss new file mode 100644 index 000000000..6fbaf05ed --- /dev/null +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionTimelineStrip.module.scss @@ -0,0 +1,10 @@ +.track { + &:focus-visible { + outline: 3px solid var(--bs-focus-ring-color, rgb(230 28 110 / 0.55)); + outline-offset: 3px; + } + + &:active { + cursor: grabbing !important; + } +} diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionTimelineStrip.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionTimelineStrip.tsx new file mode 100644 index 000000000..c14bf4ffa --- /dev/null +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/EvolutionTimelineStrip.tsx @@ -0,0 +1,349 @@ +import type { TableEvolutionEventAPI } from "#types/api-returns"; + +import { FormatDate } from "#util/time"; +import React, { useCallback, useMemo, useRef } from "react"; +import { type GameConfig, GetScoreMetricConf } from "tachi-common"; + +import timelineStyles from "./EvolutionTimelineStrip.module.scss"; +import { evoEventTimeMs } from "./folderTableShared"; + +/** Horizontal resolution of the density graph (few DOM nodes vs one per milestone). */ +const TIMELINE_BIN_COUNT = 200; + +const VIEW_BOX_H = 120; +const PLOT_TOP = 8; +const PLOT_BOTTOM = VIEW_BOX_H - 10; + +const GRAPH_CSS_HEIGHT_PX = VIEW_BOX_H; + +export default function EvolutionTimelineStrip({ + allEnumColours, + axisSpan, + enumMetric, + gameConfig, + maxAxis, + minAxis, + rangeId, + rangeStep, + scrubTimeMs, + setPlaying, + setScrubTimeMs, + workingEvents, +}: { + allEnumColours: Record> | undefined; + axisSpan: number; + enumMetric: string; + gameConfig: GameConfig; + maxAxis: number; + minAxis: number; + rangeId: string; + rangeStep: number; + scrubTimeMs: number; + setPlaying: (v: boolean) => void; + setScrubTimeMs: (v: number) => void; + workingEvents: TableEvolutionEventAPI[]; +}) { + const trackRef = useRef(null); + + const scrubClampedMs = Math.min(Math.max(scrubTimeMs, minAxis), maxAxis); + const playheadLeftPct = + axisSpan <= 0 + ? 0 + : Math.min(100, Math.max(0, (100 * (scrubClampedMs - minAxis)) / axisSpan)); + + const scrubFromClientX = useCallback( + (clientX: number) => { + const el = trackRef.current; + if (!el || workingEvents.length === 0 || axisSpan <= 0) { + return; + } + + const { left, width } = el.getBoundingClientRect(); + if (width <= 0) { + return; + } + + const ratio = Math.min(1, Math.max(0, (clientX - left) / width)); + const nextMs = minAxis + ratio * axisSpan; + setScrubTimeMs(Math.min(maxAxis, Math.max(minAxis, nextMs))); + }, + [axisSpan, maxAxis, minAxis, setScrubTimeMs, workingEvents.length], + ); + + const onPointerDownTrack = useCallback( + (e: React.PointerEvent) => { + if (workingEvents.length === 0) { + return; + } + + e.currentTarget.setPointerCapture(e.pointerId); + setPlaying(false); + scrubFromClientX(e.clientX); + }, + [scrubFromClientX, setPlaying, workingEvents.length], + ); + + const onPointerMoveTrack = useCallback( + (e: React.PointerEvent) => { + if (workingEvents.length === 0) { + return; + } + + if (!(e.pressure > 0 || e.buttons & 1)) { + return; + } + + scrubFromClientX(e.clientX); + }, + [scrubFromClientX, workingEvents.length], + ); + + const releasePointerCaptureIfAny = useCallback((e: React.PointerEvent) => { + try { + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + } catch { + // ignore races with unmount/cancelled pointers + } + }, []); + + const onSliderKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (workingEvents.length === 0) { + return; + } + + const stepMs = Number.isFinite(rangeStep) ? rangeStep : 1000; + + let nextMs = scrubClampedMs; + + switch (e.key) { + case "ArrowLeft": + case "ArrowDown": + nextMs -= stepMs; + e.preventDefault(); + break; + + case "ArrowRight": + case "ArrowUp": + nextMs += stepMs; + e.preventDefault(); + break; + + case "Home": + nextMs = minAxis; + e.preventDefault(); + break; + + case "End": + nextMs = maxAxis; + e.preventDefault(); + break; + + case "PageDown": + nextMs += axisSpan * 0.1; + e.preventDefault(); + break; + + case "PageUp": + nextMs -= axisSpan * 0.1; + e.preventDefault(); + break; + + default: + return; + } + + setPlaying(false); + setScrubTimeMs(Math.min(maxAxis, Math.max(minAxis, nextMs))); + }, + [ + axisSpan, + maxAxis, + minAxis, + rangeStep, + scrubClampedMs, + setPlaying, + setScrubTimeMs, + workingEvents.length, + ], + ); + + const graphBody = useMemo(() => { + const conf = GetScoreMetricConf(gameConfig, enumMetric); + const metricEnumLen = conf.type === "ENUM" ? conf.values.length : 1; + const enumLen = Math.max(metricEnumLen, 1); + + const bins: number[][] = Array.from({ length: TIMELINE_BIN_COUNT }, () => + new Array(enumLen).fill(0), + ); + + if (workingEvents.length === 0) { + return null; + } + + const span = Math.max(axisSpan, Number.EPSILON); + for (const evNotch of workingEvents) { + const tNotch = evoEventTimeMs(evNotch); + const rel = (tNotch - minAxis) / span; + if (!(rel >= 0 && rel <= 1)) { + continue; + } + let bucket = Math.floor(rel * TIMELINE_BIN_COUNT); + if (bucket >= TIMELINE_BIN_COUNT) { + bucket = TIMELINE_BIN_COUNT - 1; + } + if (bucket < 0) { + bucket = 0; + } + + let enumIdx = 0; + if (conf.type === "ENUM" && metricEnumLen > 0) { + enumIdx = Math.min(Math.max(evNotch.enumIndex, 0), metricEnumLen - 1); + } + bins[bucket][enumIdx] += 1; + } + + let maxBucketTotal = 0; + for (const row of bins) { + let sum = 0; + for (const c of row) { + sum += c; + } + if (sum > maxBucketTotal) { + maxBucketTotal = sum; + } + } + const norm = maxBucketTotal > 0 ? maxBucketTotal : 1; + + const plots: React.ReactElement[] = []; + const bucketW = TIMELINE_BIN_COUNT; + const inset = 0.1; + const barW = 1 - 2 * inset; + + for (let bi = 0; bi < TIMELINE_BIN_COUNT; bi++) { + const row = bins[bi]; + let bucketTotal = 0; + for (const c of row) { + bucketTotal += c; + } + if (bucketTotal === 0) { + continue; + } + + const bucketH = (bucketTotal / norm) * (PLOT_BOTTOM - PLOT_TOP); + let yBottom = PLOT_BOTTOM; + + for (let ej = row.length - 1; ej >= 0; ej--) { + const count = row[ej]; + if (count === 0 || bucketTotal === 0) { + continue; + } + const segH = (count / bucketTotal) * bucketH; + const yTop = yBottom - segH; + const valueLabel = + conf.type === "ENUM" && metricEnumLen > 0 ? (conf.values[ej] ?? "") : ""; + const colour = + valueLabel !== "" + ? (allEnumColours?.[enumMetric]?.[valueLabel] ?? "var(--bs-secondary)") + : "var(--bs-secondary)"; + + plots.push( + , + ); + yBottom = yTop; + } + } + + return ( + + + {plots} + + ); + }, [allEnumColours, axisSpan, enumMetric, gameConfig, minAxis, workingEvents]); + + const interactive = workingEvents.length > 0; + + return ( + <> +

+ Drag or click the chart to move in time - use arrow keys when the + chart has focus for keyboard scrubbing. +

+ +
+ + Milestone density (decorative). + {graphBody} + + + {interactive ? ( +
+ ) : null} +
+ + ); +} diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumDistributionBreakdown.module.scss b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumDistributionBreakdown.module.scss new file mode 100644 index 000000000..49db21322 --- /dev/null +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumDistributionBreakdown.module.scss @@ -0,0 +1,33 @@ +.enumRowInteractive { + cursor: pointer; + transition: + transform 120ms ease, + box-shadow 120ms ease, + filter 120ms ease; + + &:hover { + transform: translateY(-1px); + box-shadow: + var(--bs-box-shadow-sm), + 0 0 0 1px color-mix(in srgb, var(--bs-emphasis-color) 12%, transparent); + filter: brightness(1.03); + } + + &:focus-visible { + outline: 2px solid var(--bs-primary); + outline-offset: 2px; + } + + &:active { + transform: translateY(0); + filter: brightness(0.99); + } +} + +.enumRowButtonReset { + all: unset; + box-sizing: border-box; + cursor: inherit; + display: block; + width: 100%; +} diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumDistributionBreakdown.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumDistributionBreakdown.tsx new file mode 100644 index 000000000..036b21793 --- /dev/null +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumDistributionBreakdown.tsx @@ -0,0 +1,267 @@ +import type { FolderStatsInfo } from "#types/api-returns"; + +import { WindowContext } from "#context/WindowContext"; +import { ChangeOpacity } from "#util/color-opacity"; +import { ToFixedFloor, UppercaseFirst } from "#util/misc"; +import React, { useContext } from "react"; +import { type GameConfig, GetScoreMetricConf } from "tachi-common"; + +import breakdownStyles from "./FolderEnumDistributionBreakdown.module.scss"; + +/** Row wash (track) uses a faint tint of the ladder colour; the fill bar uses a stronger tint of that same ladder colour — no neutral body BG. */ +function distributionRowChrome(enumColour: string | undefined): { + borderLeftColor: string; + fillStrong: string | undefined; + trackBg: string; +} { + const fallbackTrack = + "color-mix(in srgb, var(--bs-secondary) 14%, transparent)"; + const fallbackFillStrong = + "color-mix(in srgb, var(--bs-secondary) 42%, transparent)"; + + const borderLeftColor = enumColour ?? "var(--bs-secondary)"; + + if ( + enumColour && + (enumColour.startsWith("#") || enumColour.startsWith("rgb")) + ) { + try { + return { + borderLeftColor: enumColour, + fillStrong: ChangeOpacity(enumColour, 0.5), + trackBg: ChangeOpacity(enumColour, 0.14), + }; + } catch { + /* fall through */ + } + } + + if (enumColour) { + return { + borderLeftColor: enumColour, + fillStrong: `color-mix(in srgb, ${enumColour} 48%, transparent)`, + trackBg: `color-mix(in srgb, ${enumColour} 14%, transparent)`, + }; + } + + return { + borderLeftColor, + fillStrong: fallbackFillStrong, + trackBg: fallbackTrack, + }; +} + +/** + * Styled enum value × count breakdown (counts + % of folder charts). + * Rows use GPT enum colours; optional floor at `minimumRelevantValue`; remainder row for unmatched charts. + */ +export default function FolderEnumDistributionBreakdown({ + clipToMinimumRelevance = false, + colours, + enumMetric, + gameConfig, + onActivate, + onEnumBreakdownRowClick, + remainderLabel, + selected, + stats, + suppressTopRule = false, +}: { + clipToMinimumRelevance?: boolean; + colours: Record | undefined; + enumMetric: string; + gameConfig: GameConfig; + onActivate?: () => void; + /** Desktop `(isLg)`: activates folder chart filter + scroll; ignored on narrow viewports. */ + onEnumBreakdownRowClick?: (enumValueLabel: string) => void; + remainderLabel?: string; + /** Highlights this block when picking the active evolution replay metric from Overview. */ + selected?: boolean; + /** Folder-level stats blob from `GET .../folders/:slug/stats`, or replay slice with same shape. */ + stats: FolderStatsInfo; + /** When stacking inside one card, skip the inset top rule on the first section. */ + suppressTopRule?: boolean; +}) { + const remainderLine = remainderLabel ?? "Not played"; + + const { + breakpoint: { isLg }, + } = useContext(WindowContext); + + const conf = GetScoreMetricConf(gameConfig, enumMetric); + if (conf.type !== "ENUM") { + return null; + } + + const total = stats.chartCount; + const bucket = stats.stats[enumMetric] ?? {}; + + const minRelIx = conf.values.indexOf(conf.minimumRelevantValue); + const valueIndexFloor = + clipToMinimumRelevance && minRelIx !== -1 ? minRelIx : 0; + + if (total === 0) { + return No charts in this folder.; + } + + let filled = 0; + const rows = []; + for (let vi = conf.values.length - 1; vi >= valueIndexFloor; vi--) { + const label = conf.values[vi]; + const count = bucket[label] ?? 0; + + filled += count; + + const pct = total > 0 ? (100 * count) / total : 0; + const chipFill = colours?.[label]; + const chrome = distributionRowChrome(chipFill); + const pctWidth = + pct > 0 ? `${ToFixedFloor(Math.min(100, Math.max(0, pct)), 3)}%` : "0%"; + + const clickableRow = + Boolean(onEnumBreakdownRowClick && isLg && count > 0); + const rowHoverChromeClass = + clickableRow ? breakdownStyles.enumRowInteractive : ""; + + const rowShellStyle = { + backgroundColor: chrome.trackBg, + border: "1px solid var(--bs-border-color-translucent)", + borderLeftColor: chrome.borderLeftColor, + borderLeftStyle: "solid" as const, + borderLeftWidth: 4, + borderRadius: "var(--bs-border-radius-lg)", + opacity: count === 0 ? 0.62 : undefined, + }; + + const rowInterior = ( + <> + {chrome.fillStrong && pct > 0 ? ( +
+ ) : null} +
+ + + {label} + + + {count} + + ({ToFixedFloor(pct, 1)}%) + + +
+ + ); + + rows.push( + clickableRow && onEnumBreakdownRowClick ? ( + + ) : ( +
+ {rowInterior} +
+ ), + ); + } + + const remainder = Math.max(0, total - filled); + const remainderPct = total > 0 ? (100 * remainder) / total : 0; + + return ( +
+
+
+ {onActivate ? ( + + ) : ( +
+ {UppercaseFirst(enumMetric)} breakdown +
+ )} +
+
{rows}
+ {remainder > 0 ? ( +
+
+
+ {remainderLine} + + {remainder} + + ({ToFixedFloor(remainderPct, 1)}%) + + +
+
+ ) : null} +
+
+ ); +} diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumProgressBar.module.scss b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumProgressBar.module.scss new file mode 100644 index 000000000..2468c1f5c --- /dev/null +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumProgressBar.module.scss @@ -0,0 +1,64 @@ +@keyframes folderEnumBarEnter { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes folderEnumSegmentFade { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.folderEnumBar { + position: relative; +} + +.folderEnumBarAnimated { + animation: folderEnumBarEnter 0.42s ease-out both; +} + +.folderEnumBarSegment { + align-items: center; + display: flex; + flex: none; + height: 100%; + justify-content: center; + min-width: 0; + overflow: hidden; + position: relative; +} + +.folderEnumBarSegmentAnimated { + animation: folderEnumSegmentFade 0.38s ease-out both; +} + +.folderEnumBarRemainder { + flex: none; +} + +.folderEnumBarRemainderAnimated { + animation: folderEnumSegmentFade 0.38s ease-out both; +} + +.folderEnumBarLabel { + color: #fff; + font-size: 0.65rem; + font-weight: 700; + line-height: 1; + pointer-events: none; + text-shadow: + 0 0 1px rgb(0 0 0 / 0.9), + 0 1px 2px rgb(0 0 0 / 0.55); + white-space: nowrap; +} diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumProgressBar.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumProgressBar.tsx new file mode 100644 index 000000000..b2c8ae3cc --- /dev/null +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumProgressBar.tsx @@ -0,0 +1,179 @@ +import type { FolderStatsInfo } from "#types/api-returns"; + +import QuickTooltip from "#components/layout/misc/QuickTooltip"; +import Muted from "#components/util/Muted"; +import { ChangeOpacity } from "#util/color-opacity"; +import { ToFixedFloor } from "#util/misc"; +import React, { useLayoutEffect, useRef, useState } from "react"; +import { type GameConfig, GetScoreMetricConf } from "tachi-common"; + +import barStyles from "./FolderEnumProgressBar.module.scss"; + +const FOLDER_ENUM_BAR_HEIGHT = "1.625rem"; + +/** In-bar `%` text only when the segment is at least this wide (measured in CSS pixels). */ +const MIN_SEGMENT_WIDTH_PX_FOR_INLINE_LABEL = 30; + +export default function FolderEnumProgressBar({ + animateSegments = true, + clipToMinimumRelevance = false, + stats, + enumMetric, + gameConfig, + colours, +}: { + animateSegments?: boolean; + /** When true, only segments at or stronger than `minimumRelevantValue` contribute (matches evolution semantics). */ + clipToMinimumRelevance?: boolean; + colours: Record | undefined; + enumMetric: string; + gameConfig: GameConfig; + stats: FolderStatsInfo; +}) { + const barRef = useRef(null); + const [barWidthPx, setBarWidthPx] = useState(0); + + useLayoutEffect(() => { + const el = barRef.current; + if (!el) { + return; + } + + const update = () => { + setBarWidthPx(el.getBoundingClientRect().width); + }; + + update(); + const ro = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width; + if (w !== undefined) { + setBarWidthPx(w); + } + }); + ro.observe(el); + return () => { + ro.disconnect(); + }; + }, []); + + const conf = GetScoreMetricConf(gameConfig, enumMetric); + if (conf.type !== "ENUM") { + return null; + } + + const total = stats.chartCount; + const bucket = stats.stats[enumMetric] ?? {}; + + const minRelIx = conf.values.indexOf(conf.minimumRelevantValue); + const valueIndexFloor = + clipToMinimumRelevance && minRelIx !== -1 ? minRelIx : 0; + + const segments: Array<{ count: number; label: string; rawFill: string }> = []; + let filled = 0; + for (let vi = conf.values.length - 1; vi >= valueIndexFloor; vi--) { + const v = conf.values[vi]; + const count = bucket[v] ?? 0; + if (count > 0) { + filled += count; + segments.push({ + label: v, + count, + rawFill: colours?.[v] ?? "var(--bs-secondary-bg)", + }); + } + } + + if (total === 0) { + return —; + } + + const remainder = Math.max(0, total - filled); + const remainderWidthPct = total > 0 ? (100 * remainder) / total : 0; + + return ( +
+
+ {segments.map((seg, segIndex) => { + const pct = total > 0 ? (100 * seg.count) / total : 0; + const fill = seg.rawFill.startsWith("var(") + ? seg.rawFill + : ChangeOpacity(seg.rawFill, 0.92); + const segmentWidthPx = barWidthPx > 0 ? (pct / 100) * barWidthPx : 0; + const showInlinePct = segmentWidthPx >= MIN_SEGMENT_WIDTH_PX_FOR_INLINE_LABEL; + const inlinePctText = `${Math.round(pct)}%`; + + return ( + +
{seg.label}
+
{ToFixedFloor(pct, 2)}%
+ + ({seg.count} / {total}) + + + } + > +
+ {showInlinePct && ( + + {inlinePctText} + + )} +
+
+ ); + })} + {remainderWidthPct > 0 && ( +
+ )} +
+
+ ); +} diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.module.scss b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.module.scss index a118993d7..cac10cad2 100644 --- a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.module.scss +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.module.scss @@ -1,57 +1,3 @@ -@keyframes folderEnumBarEnter { - from { - opacity: 0; - transform: translateY(4px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes folderEnumSegmentFade { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -.folderEnumBar { - animation: folderEnumBarEnter 0.42s ease-out both; -} - -.folderEnumBarSegment { - align-items: center; - animation: folderEnumSegmentFade 0.38s ease-out both; - display: flex; - flex: none; - height: 100%; - justify-content: center; - min-width: 0; - overflow: hidden; - position: relative; -} - -.folderEnumBarRemainder { - animation: folderEnumSegmentFade 0.38s ease-out both; -} - -.folderEnumBarLabel { - color: #fff; - font-size: 0.65rem; - font-weight: 700; - line-height: 1; - pointer-events: none; - text-shadow: - 0 0 1px rgb(0 0 0 / 0.9), - 0 1px 2px rgb(0 0 0 / 0.55); - white-space: nowrap; -} - .folderRowChartMeta { align-items: center; column-gap: 0.5rem; @@ -82,6 +28,32 @@ } } +@keyframes folder-row-highlight-intro { + 0% { + background-color: transparent; + border-color: var(--bs-border-color); + box-shadow: 0 0 0 color-mix(in srgb, var(--bs-primary) 0%, transparent); + transform: translateY(0); + } + 50% { + background-color: color-mix(in srgb, var(--bs-primary) 18%, var(--bs-body-bg)); + border-color: color-mix(in srgb, var(--bs-primary) 65%, var(--bs-border-color)); + box-shadow: + 0 0.25rem 1.15rem color-mix(in srgb, var(--bs-primary) 32%, transparent), + 0 0 0 1px color-mix(in srgb, var(--bs-primary) 40%, transparent); + transform: translateY(-3px); + } + 100% { + background-color: color-mix(in srgb, var(--bs-primary) 12%, var(--bs-body-bg)); + border-color: color-mix(in srgb, var(--bs-primary) 58%, var(--bs-border-color)); + box-shadow: + 0 0.22rem 0.85rem color-mix(in srgb, var(--bs-primary) 28%, transparent), + 0 0 0 1px color-mix(in srgb, var(--bs-primary) 38%, transparent), + inset 3px 0 0 0 var(--bs-primary); + transform: translateY(-1px); + } +} + .folderRow { border: 1px solid var(--bs-border-color); border-radius: var(--bs-border-radius-xl); @@ -108,3 +80,35 @@ outline-offset: 2px; } } + +/** Row for the folder that matches `highlightFolderSlug`; intro bumps when the overview accordion opens (`FolderTablePage`). */ +.folderRowHighlighted { + animation: folder-row-highlight-intro 1.15s cubic-bezier(0.33, 0.82, 0.35, 1) both; + border-width: 2px; + + &:hover { + background-color: color-mix(in srgb, var(--bs-primary) 16%, var(--bs-body-bg)); + border-color: color-mix(in srgb, var(--bs-primary) 72%, var(--bs-border-color)); + box-shadow: + 0 0.35rem 1.1rem color-mix(in srgb, var(--bs-primary) 30%, transparent), + 0 0 0 1px color-mix(in srgb, var(--bs-primary) 42%, transparent), + inset 3px 0 0 0 var(--bs-primary); + } + + &:focus-visible { + outline: 2px solid var(--bs-primary); + outline-offset: 2px; + } + + @media (prefers-reduced-motion: reduce) { + animation: none; + background-color: color-mix(in srgb, var(--bs-primary) 12%, var(--bs-body-bg)); + border-color: color-mix(in srgb, var(--bs-primary) 55%, var(--bs-border-color)); + border-left-width: 6px; + border-width: 2px; + box-shadow: + 0 0.18rem 0.65rem color-mix(in srgb, var(--bs-primary) 22%, transparent), + inset 3px 0 0 0 var(--bs-primary); + padding-inline-start: calc(1rem - 4px); + } +} diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.tsx index 6ac615ae2..ed5c39096 100644 --- a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FolderTablePage.tsx @@ -1,50 +1,84 @@ -import QuickTooltip from "#components/layout/misc/QuickTooltip"; -import Card from "#components/layout/page/Card"; +import type { TableDocument } from "tachi-common"; + 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 SelectButton from "#components/util/SelectButton"; -import { useBucket } from "#components/util/useBucket"; import useLUGPTSettings from "#components/util/useLUGPTSettings"; -import { UserContext } from "#context/UserContext"; -import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations"; -import { type FolderStatsInfo, type UGPTTableReturns } from "#types/api-returns"; -import { APIFetchV1 } from "#util/api"; -import { ChangeOpacity } from "#util/color-opacity"; -import { ToFixedFloor, UppercaseFirst } from "#util/misc"; -import React, { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; +import Button from "react-bootstrap/Button"; +import Collapse from "react-bootstrap/Collapse"; import Form from "react-bootstrap/Form"; import InputGroup from "react-bootstrap/InputGroup"; -import { Link } from "react-router-dom"; -import { - type FolderDocument, - type GameConfig, - GetGameConfig, - GetScoreMetricConf, - GetScoreMetrics, - type TableDocument, - type UserDocument, - type V3Game, -} from "tachi-common"; +import { matchPath, useHistory, useLocation } from "react-router-dom"; -import folderTableStyles from "./FolderTablePage.module.scss"; +import type { FolderTableScopedProps } from "./folderTableShared"; -interface Props { - reqUser: UserDocument; - game: V3Game; -} +import TableFolderViewer from "./TableFolderViewer"; -export default function FolderTablePage({ reqUser, game }: Props) { +const FOLDER_ROUTE_PATTERN = "/u/:userID/games/:game/folders/:folderSlug"; + +export default function FolderTablePage({ reqUser, game }: FolderTableScopedProps) { const { data, error } = useApiQuery(`/games/${game}/tables?showInactive=true`); const { settings } = useLUGPTSettings(); + const location = useLocation(); + const history = useHistory(); + + const folderRouteMatch = useMemo( + () => + matchPath<{ folderSlug: string }>(location.pathname, { + exact: true, + path: FOLDER_ROUTE_PATTERN, + }), + [location.pathname], + ); + + const activeFolderSlug = folderRouteMatch?.params.folderSlug ?? undefined; + + const [tableOverviewOpen, setTableOverviewOpen] = useState(true); + const overviewPanelId = useMemo(() => `folder-table-overview-${game}`, [game]); + + const [highlightRevealKey, setHighlightRevealKey] = useState(0); + const [tableID, setTableID] = useState(""); const [tableMap, setTableMap] = useState(new Map()); + const collapseTableOverview = useCallback(() => { + setTableOverviewOpen(false); + }, []); + + const onTablePickChange = useCallback( + (nextTableID: string) => { + setTableID(nextTableID); + if (!activeFolderSlug) { + return; + } + + history.replace(`/u/${reqUser.username}/games/${game}/folders`); + }, + [activeFolderSlug, game, history, reqUser.username], + ); + + useLayoutEffect(() => { + if (activeFolderSlug) { + setTableOverviewOpen(false); + return; + } + + setTableOverviewOpen(true); + }, [activeFolderSlug]); + + useEffect(() => { + if (!activeFolderSlug || !tableOverviewOpen) { + return; + } + + setHighlightRevealKey((n) => n + 1); + }, [activeFolderSlug, tableOverviewOpen]); + const table = useMemo(() => tableMap.get(tableID), [tableID, tableMap]); useEffect(() => { @@ -55,13 +89,11 @@ export default function FolderTablePage({ reqUser, game }: Props) { for (const table of data) { newMap.set(table.tableID, table); - // If the user has a preference if (settings?.preferences.defaultTable) { if (settings.preferences.defaultTable === table.tableID) { setTableID(table.tableID); foundDefault = true; } - // Otherwise just use the provided default. } if (table.default && !foundDefault) { @@ -92,358 +124,64 @@ export default function FolderTablePage({ reqUser, game }: Props) { (settings?.preferences.defaultTable && settings.preferences.defaultTable === e.tableID), ); - return ( - <> - - Table - setTableID(e.target.value)} value={tableID}> - {displayableTables.map((e) => ( - - ))} - - - - {table && } - + const tablePicker = ( + + Table + onTablePickChange(e.target.value)} value={tableID}> + {displayableTables.map((e) => ( + + ))} + + ); -} - -interface UGPTFolderStats { - folder: FolderDocument; - stats: FolderStatsInfo; -} - -/** API `table.folders` is ascending (e.g. Level 1 … 12); show highest folder / level first in chart + table. */ -function tableFolderSlugsDisplayOrder(table: TableDocument): string[] { - return [...table.folders].reverse(); -} - -const FOLDER_ENUM_BAR_HEIGHT = "1.625rem"; - -/** In-bar `%` text only when the segment is at least this wide (measured in CSS pixels). */ -const MIN_SEGMENT_WIDTH_PX_FOR_INLINE_LABEL = 30; - -function FolderEnumProgressBar({ - stats, - enumMetric, - gameConfig, - colours, -}: { - colours: Record | undefined; - enumMetric: string; - gameConfig: GameConfig; - stats: FolderStatsInfo; -}) { - const barRef = useRef(null); - const [barWidthPx, setBarWidthPx] = useState(0); - - useLayoutEffect(() => { - const el = barRef.current; - if (!el) { - return; - } - - const update = () => { - setBarWidthPx(el.getBoundingClientRect().width); - }; - - update(); - const ro = new ResizeObserver((entries) => { - const w = entries[0]?.contentRect.width; - if (w !== undefined) { - setBarWidthPx(w); - } - }); - ro.observe(el); - return () => { - ro.disconnect(); - }; - }, []); - - const conf = GetScoreMetricConf(gameConfig, enumMetric); - if (conf.type !== "ENUM") { - return null; - } - - const total = stats.chartCount; - const bucket = stats.stats[enumMetric] ?? {}; - - const segments: Array<{ count: number; label: string; rawFill: string }> = []; - let filled = 0; - for (let vi = conf.values.length - 1; vi >= 0; vi--) { - const v = conf.values[vi]; - const count = bucket[v] ?? 0; - if (count > 0) { - filled += count; - segments.push({ - label: v, - count, - rawFill: colours?.[v] ?? "var(--bs-secondary-bg)", - }); - } - } - - if (total === 0) { - return —; - } - - const remainder = Math.max(0, total - filled); - const remainderWidthPct = total > 0 ? (100 * remainder) / total : 0; - - return ( -
-
- {segments.map((seg, segIndex) => { - const pct = total > 0 ? (100 * seg.count) / total : 0; - const fill = seg.rawFill.startsWith("var(") - ? seg.rawFill - : ChangeOpacity(seg.rawFill, 0.92); - const segmentWidthPx = barWidthPx > 0 ? (pct / 100) * barWidthPx : 0; - const showInlinePct = segmentWidthPx >= MIN_SEGMENT_WIDTH_PX_FOR_INLINE_LABEL; - const inlinePctText = `${Math.round(pct)}%`; - - return ( - -
{seg.label}
-
{ToFixedFloor(pct, 2)}%
- - ({seg.count} / {total}) - - - } - > -
- {showInlinePct && ( - - {inlinePctText} - - )} -
-
- ); - })} - {remainderWidthPct > 0 && ( -
- )} -
-
- ); -} - -function TableFolderViewer({ reqUser, game, table }: { table: TableDocument } & Props) { - 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>(new Map()); - const [hasLoadedFolderMap, setHasLoadedFolderMap] = useState(false); - - useEffect(() => { - if (data) { - const statMap = new Map(); - for (const stat of data.stats) { - statMap.set(stat.slug, stat); - } - - const newMap = new Map(); - for (const folder of data.folders) { - const stats = statMap.get(folder.slug)!; - newMap.set(folder.slug, { folder, stats }); - } - setDataMap(newMap); - setHasLoadedFolderMap(true); - } - }, [data]); - - const gameConfig = useMemo(() => GetGameConfig(game), [game]); - const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game]; - - if (error) { - return ; - } - - if (!data || !hasLoadedFolderMap) { - return ; - } return ( <> - -
-
-
- {GetScoreMetrics(gameConfig, "ENUM").map((e) => ( - - {/* @ts-expect-error enum icon keys align with score metrics */} - {UppercaseFirst(e)}s - - ))} -
-
-
-
- - - - ); -} - -function TableFolderList({ - table, - dataMap, - enumMetric, - reqUser, - game, -}: { - dataMap: Map; - enumMetric: string; - table: TableDocument; -} & Props) { - 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) => ( - + + +
+ {tablePicker} + + {table ? ( + -
-
- - {data.stats.chartCount} - - -
+ ) : null}
- - )) + +
+ ) : ( + <> + {tablePicker} + + {table ? : null} + )} -
+ ); } diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage.tsx index 352c0fdb0..e058753e2 100644 --- a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/FoldersMainPage.tsx @@ -1,4 +1,5 @@ import useSetSubheader from "#components/layout/header/useSetSubheader"; +import Divider from "#components/util/Divider"; import { type UGPT } from "#types/react"; import React from "react"; import { Redirect, Route, Switch } from "react-router-dom"; @@ -24,9 +25,6 @@ export default function FoldersMainPage({ reqUser, game }: UGPT) {
- - - )} /> - - + + <> + + + <> + + + + +
diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/SpecificFolderPage.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/SpecificFolderPage.tsx index 9fd5ac421..f54995655 100644 --- a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/SpecificFolderPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/SpecificFolderPage.tsx @@ -1,11 +1,11 @@ import FolderInfoHeader from "#components/game/folder/FolderInfoHeader"; import QuickTooltip from "#components/layout/misc/QuickTooltip"; -import Card from "#components/layout/page/Card"; import DifficultyCell from "#components/tables/cells/DifficultyCell"; -import TimestampCell from "#components/tables/cells/TimestampCell"; -import TitleCell from "#components/tables/cells/TitleCell"; import MiniTable from "#components/tables/components/MiniTable"; -import FolderTable from "#components/tables/folders/FolderTable"; +import FolderTable, { + FOLDER_FOLDER_TABLE_SCROLL_INTO_VIEW_ID, + type FolderEnumBreakdownTablePreset, +} from "#components/tables/folders/FolderTable"; import ScoreCoreCells from "#components/tables/game-core-cells/ScoreCoreCells"; import ApiError from "#components/util/ApiError"; import Divider from "#components/util/Divider"; @@ -23,13 +23,10 @@ import { type GPTRatingSystem } from "#lib/types"; import { type UGPTFolderReturns } from "#types/api-returns"; import { type FolderDataset } from "#types/tables"; import { ChangeOpacity } from "#util/color-opacity"; -import { ONE_DAY } from "#util/constants/time"; import { CreateChartIDMap, CreateChartLink, CreateSongMap } from "#util/data"; -import { DistinctArr, ToFixedFloor, UppercaseFirst } from "#util/misc"; +import { DistinctArr, ToFixedFloor } from "#util/misc"; import { NumericSOV, StrSOV } from "#util/sorts"; -import { FormatDate } from "#util/time"; import React, { - type CSSProperties, type SetStateAction, useCallback, useContext, @@ -40,25 +37,25 @@ import React, { useState, } from "react"; import { Col, Collapse, Form, Row } from "react-bootstrap"; -import { Link, Route, Switch, useHistory, useLocation, useParams } from "react-router-dom"; +import { Link, Redirect, Route, Switch, useHistory, useLocation, useParams } from "react-router-dom"; import { - type ChartDocument, COLOUR_SET, EnumIndexToValue, FormatDifficultyShort, GetGameConfig, GetScoreEnumConfs, type integer, - type ScoreDocument, - type SongDocument, type UserDocument, type V3Game, } from "tachi-common"; -import { type ConfEnumScoreMetric } from "tachi-common/types/metrics"; import FolderComparePage from "./FolderComparePage"; import FolderQuestsPage from "./FolderQuestsPage"; import tierlistStyles from "./SpecificFolderPage.module.scss"; +import TableEvolutionReplay from "./TableEvolutionReplay"; + +/** Extra offset when jumping from breakdown → folder table (`scrollIntoView` start − this). */ +const FOLDER_BREAKDOWN_TABLE_SCROLL_EXTRA_OFFSET_PX = 50; interface Props { reqUser: UserDocument; @@ -96,7 +93,29 @@ export default function SpecificFolderPage({ reqUser, game }: Props) { folderDataset.sort(StrSOV((x) => x.__related.song.title)); return folderDataset; - }, [data]); + }, [data, reqUser]); + + const [folderTableEnumPreset, setFolderTableEnumPreset] = + useState(null); + + const onBreakdownEnumValueClick = useCallback((metricKey: string, valueLabel: string) => { + setFolderTableEnumPreset((p) => ({ + metricKey, + valueLabel, + nonce: (p?.nonce ?? 0) + 1, + })); + + queueMicrotask(() => { + const anchor = document.getElementById(FOLDER_FOLDER_TABLE_SCROLL_INTO_VIEW_ID); + if (!anchor) return; + + const y = + anchor.getBoundingClientRect().top + + window.scrollY - + FOLDER_BREAKDOWN_TABLE_SCROLL_EXTRA_OFFSET_PX; + window.scrollTo({ behavior: "smooth", top: Math.max(0, y) }); + }); + }, []); const folderInfoHeader = useMemo(() => { if (!folderDataset || !data) { @@ -105,13 +124,14 @@ export default function SpecificFolderPage({ reqUser, game }: Props) { return ( ); - }, [folderDataset]); + }, [data, folderSlug, folderDataset, game, onBreakdownEnumValueClick, reqUser]); const base = `${useUGPTBase({ reqUser, game })}/folders/${folderSlug}`; @@ -146,9 +166,6 @@ export default function SpecificFolderPage({ reqUser, game }: Props) { Tierlist View )} - - Timeline View - Compare Against User @@ -163,7 +180,13 @@ export default function SpecificFolderPage({ reqUser, game }: Props) {
- + - + @@ -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 + + {playbackSpeed}× + + + + + {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>(new Map()); + const [hasLoadedFolderMap, setHasLoadedFolderMap] = useState(false); + + useEffect(() => { + if (data) { + const statMap = new Map(); + for (const stat of data.stats) { + statMap.set(stat.slug, stat); + } + + const newMap = new Map(); + for (const folder of data.folders) { + const stats = statMap.get(folder.slug)!; + newMap.set(folder.slug, { folder, stats }); + } + setDataMap(newMap); + setHasLoadedFolderMap(true); + } + }, [data]); + + const gameConfig = useMemo(() => GetGameConfig(game), [game]); + + const evolutionScope = useMemo( + () => ({ kind: "table" as const, table }), + [table], + ); + + if (error) { + return ; + } + + if (!data || !hasLoadedFolderMap) { + return ; + } + + return ( + <> + + + + + ); +} diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/folderTableShared.ts b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/folderTableShared.ts new file mode 100644 index 000000000..7083a64e1 --- /dev/null +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/folders/folderTableShared.ts @@ -0,0 +1,75 @@ +import type { + FolderStatsInfo, + TableEvolutionEventAPI, +} from "#types/api-returns"; +import { + GetScoreMetricConf, + type FolderDocument, + type GameConfig, + type integer, + type TableDocument, + type UserDocument, + type V3Game, +} from "tachi-common"; + +export type FolderTableScopedProps = { + game: V3Game; + reqUser: UserDocument; +}; + +export interface UGPTFolderStats { + folder: FolderDocument; + stats: FolderStatsInfo; +} + +/** API `table.folders` is ascending (e.g. Level 1 … 12); show highest folder / level first in chart + table. */ +export function tableFolderSlugsDisplayOrder(table: TableDocument): string[] { + return [...table.folders].reverse(); +} + +export function evoEventTimeMs(ev: TableEvolutionEventAPI): number { + return ev.timeAchieved ?? ev.timeAdded; +} + +export function buildEvolutionReplayFolderStats(opts: { + enumMetric: string; + eventsPrefix: TableEvolutionEventAPI[]; + folderChartIDs: Record; + folderSlug: string; + gameConfig: GameConfig; +}): FolderStatsInfo { + const chartIds = opts.folderChartIDs[opts.folderSlug] ?? []; + + const metricConf = GetScoreMetricConf(opts.gameConfig, opts.enumMetric); + if (metricConf.type !== "ENUM") { + return { slug: opts.folderSlug, chartCount: chartIds.length, stats: {} }; + } + + const peakByMetricByChart = new Map>(); + + for (const ev of opts.eventsPrefix) { + const forChart = peakByMetricByChart.get(ev.chartID) ?? {}; + forChart[ev.metric] = ev.enumIndex; + peakByMetricByChart.set(ev.chartID, forChart); + } + + const bucket: Record = {}; + + for (const cid of chartIds) { + const idx = peakByMetricByChart.get(cid)?.[opts.enumMetric]; + if (idx === undefined) { + continue; + } + const lbl = metricConf.values[idx]; + if (!lbl) { + continue; + } + bucket[lbl] = (bucket[lbl] ?? 0) + 1; + } + + return { + slug: opts.folderSlug, + chartCount: chartIds.length, + stats: { [opts.enumMetric]: bucket }, + }; +} diff --git a/typescript/client/src/components/game/folder/FolderInfoHeader.tsx b/typescript/client/src/components/game/folder/FolderInfoHeader.tsx index 4956967b7..3b4625fe4 100644 --- a/typescript/client/src/components/game/folder/FolderInfoHeader.tsx +++ b/typescript/client/src/components/game/folder/FolderInfoHeader.tsx @@ -1,93 +1,88 @@ +import FolderEnumDistributionBreakdown from "#app/pages/dashboard/users/games/_game/_playtype/folders/FolderEnumDistributionBreakdown"; import Card from "#components/layout/page/Card"; -import Divider from "#components/util/Divider"; -import Icon from "#components/util/Icon"; -import SelectButton from "#components/util/SelectButton"; -import { useBucket } from "#components/util/useBucket"; +import ApiError from "#components/util/ApiError"; +import Loading from "#components/util/Loading"; +import useApiQuery from "#components/util/query/useApiQuery"; import { GPT_CLIENT_IMPLEMENTATIONS } from "#lib/game-implementations"; +import { type UGPTFolderSlugStatsReturns } from "#types/api-returns"; import { type GamePT } from "#types/react"; -import { type FolderDataset } from "#types/tables"; -import { UppercaseFirst } from "#util/misc"; -import React, { useMemo, useState } from "react"; -import { GetGameConfig, GetScoreEnumConfs, type UserDocument } from "tachi-common"; - -import FolderMinimap from "./FolderMinimap"; -import FolderScoreAverages from "./FolderScoreAverages"; -import FolderScoreDistributionChart from "./FolderScoreDistributionChart"; +import React, { useMemo } from "react"; +import { GetGameConfig, GetScoreMetrics, type UserDocument } from "tachi-common"; export default function FolderInfoHeader({ - game, - reqUser, - folderDataset, + folderSlug, folderTitle, + game, + onBreakdownEnumValueClick, + reqUser, }: { - folderDataset: FolderDataset; + folderSlug: string; folderTitle: string; + onBreakdownEnumValueClick?: (metricKey: string, enumValueLabel: string) => void; reqUser: UserDocument; } & GamePT) { - const preferredDefaultEnum = useBucket(game); - - const [currentGraph, setCurrentGraph] = useState(`${preferredDefaultEnum}-stats`); - - const enumGraphs = ["minimap", "stats"]; + const folderStatsUrl = `/users/${reqUser.id}/games/${game}/folders/${folderSlug}/stats`; + const { + data: folderStatsBody, + error: folderStatsError, + isLoading: folderStatsLoading, + } = useApiQuery(folderStatsUrl); const gameConfig = GetGameConfig(game); - const gptImpl = GPT_CLIENT_IMPLEMENTATIONS[game]; - const enumConf = GetScoreEnumConfs(gameConfig); - - const [metric, type] = useMemo(() => currentGraph.split("-"), [currentGraph]); + const enumMetrics = useMemo(() => GetScoreMetrics(gameConfig, "ENUM"), [gameConfig]); + const enumColourMaps = GPT_CLIENT_IMPLEMENTATIONS[game].enumColours as + | Record> + | undefined; return ( -
-
- {enumGraphs.flatMap((g) => - Object.keys(enumConf).flatMap((en) => ( - - {/* @ts-expect-error zzz */} - {UppercaseFirst(en)}{" "} - {UppercaseFirst(g)} - - )), - )} - - Score Averages - -
+
+ {folderStatsError ? ( + + ) : folderStatsLoading ? ( + + ) : ( + (() => { + const liveFolderStats = folderStatsBody?.stats; + if (liveFolderStats === null || liveFolderStats === undefined) { + return ( + + Could not load folder statistics. + + ); + } + return ( +
+ {enumMetrics.map((metric) => ( +
+ { + onBreakdownEnumValueClick(metric, enumValueLabel); + } + : undefined + } + stats={liveFolderStats} + suppressTopRule + /> +
+ ))} +
+ ); + })() + )}
-
- -
- - {type === "stats" ? ( - - ) : type === "minimap" ? ( - - ) : ( - - )} ); } diff --git a/typescript/client/src/components/layout/misc/QuickTooltip.tsx b/typescript/client/src/components/layout/misc/QuickTooltip.tsx index 621d1e1a1..78e161cd5 100644 --- a/typescript/client/src/components/layout/misc/QuickTooltip.tsx +++ b/typescript/client/src/components/layout/misc/QuickTooltip.tsx @@ -22,7 +22,6 @@ export default function QuickTooltip({ tooltipContent: React.ReactChild | undefined; wide?: boolean; }) { - const [popperArmed, setPopperArmed] = useState(false); const [show, setShow] = useState(false); const [mousedOver, setMousedOver] = useState(false); @@ -59,26 +58,8 @@ export default function QuickTooltip({ }); } - const armPopper = () => { - if (!popperArmed) { - setPopperArmed(true); - setShow(true); - } - }; - - if (!popperArmed) { - return React.cloneElement(children, { - onMouseEnter: (e: React.MouseEvent) => { - armPopper(); - children.props.onMouseEnter?.(e); - }, - onFocus: (e: React.FocusEvent) => { - armPopper(); - children.props.onFocus?.(e); - }, - }); - } - + // Mount OverlayTrigger from the first paint. A lazy first render used cloneElement only, + // then swapped to OverlayTrigger on hover; that remounted the trigger and restarted CSS animations. return ( ({ noBottomDisplayPager = false, pageLenOptions = DEFAULT_TACHI_PAGE_LEN_OPTIONS, rowKey, + externalSearchPreset = undefined, }: { dataset: D[]; defaultReverseSort?: boolean; @@ -112,8 +114,15 @@ export default function TachiTable({ /** Stable keys for tbody rows (e.g. chartID) so filtering reconciles instead of re-mounting. */ rowKey?: (row: D) => Key; searchFunctions?: SearchFunctions; + /** + * When `nonce` changes (desktop breakpoints only), replaces the filter text — e.g. + * syncing from folder breakdown → table filter. + */ + externalSearchPreset?: { nonce: number; search: string } | null; }) { const [search, setSearch] = useState(""); + const lastExternalNonce = useRef(undefined); + const [highlightFilterPulse, setHighlightFilterPulse] = useState(false); const searchFunction = useMemo( () => (searchFunctions ? ComposeSearchFunction(searchFunctions) : undefined), @@ -161,6 +170,55 @@ export default function TachiTable({ const { breakpoint: { isLg }, } = useContext(WindowContext); + + const filterChromeClass = useMemo(() => { + const hasFilterText = search.trim().length > 0; + + return [ + tachitableStyles.filterBarChrome, + hasFilterText ? tachitableStyles.filterBarChromeActive : "", + highlightFilterPulse ? tachitableStyles.searchFilterFlash : "", + ] + .filter(Boolean) + .join(" "); + }, [highlightFilterPulse, search]); + + useEffect(() => { + if (!externalSearchPreset || !isLg) { + return; + } + + const { nonce, search: presetSearch } = externalSearchPreset; + + if (lastExternalNonce.current === nonce) { + return; + } + + lastExternalNonce.current = nonce; + setSearch(presetSearch); + + let canceled = false; + setHighlightFilterPulse(false); + + const raf = window.requestAnimationFrame(() => { + if (!canceled) { + setHighlightFilterPulse(true); + } + }); + + const t = window.setTimeout(() => { + if (!canceled) { + setHighlightFilterPulse(false); + } + }, 1250); + + return () => { + canceled = true; + window.cancelAnimationFrame(raf); + window.clearTimeout(t); + }; + }, [externalSearchPreset?.nonce, externalSearchPreset?.search, isLg]); + return (
@@ -168,20 +226,25 @@ export default function TachiTable({
{displayStr}
)} {searchFunctions && ( - - setSearch(e.target.value)} - placeholder={`Filter ${entryName}`} - type="text" - value={search} - /> - {dataset[0] && ( - + + setSearch(e.target.value)} + placeholder={`Filter ${entryName}`} + type="text" + value={search} /> - )} - + {dataset[0] && ( + + )} + +
)}
diff --git a/typescript/client/src/components/tables/folders/FolderTable.tsx b/typescript/client/src/components/tables/folders/FolderTable.tsx index 01cee7dd1..5dd2454c0 100644 --- a/typescript/client/src/components/tables/folders/FolderTable.tsx +++ b/typescript/client/src/components/tables/folders/FolderTable.tsx @@ -1,10 +1,11 @@ import Muted from "#components/util/Muted"; import usePreferredRanking from "#components/util/usePreferredRanking"; import useScoreRatingAlg from "#components/util/useScoreRatingAlg"; +import { WindowContext } from "#context/WindowContext"; import { type FolderDataset } from "#types/tables"; import { NumericSOV, StrSOV } from "#util/sorts"; import { CreateDefaultFolderSearchParams } from "#util/tables/create-search"; -import React, { memo, useCallback, useMemo, useState } from "react"; +import React, { memo, useCallback, useContext, useMemo, useState } from "react"; import { Alert, Button } from "react-bootstrap"; import { type PBScoreDocument, type ScoreRatingAlgorithms, type V3Game } from "tachi-common"; @@ -23,7 +24,30 @@ import { GetGPTCoreHeaders } from "../headers/GameHeaders"; import { EmptyHeader, FolderIndicatorHeader } from "../headers/IndicatorHeader"; import { CreateRankingHeader } from "../headers/RankingHeader"; -export default function FolderTable({ dataset, game }: { dataset: FolderDataset; game: V3Game }) { +export type FolderEnumBreakdownTablePreset = { + metricKey: string; + valueLabel: string; + nonce: number; +}; + +/** Scroll target used when jumping from folder breakdown chips to this table (desktop-only flow). */ +export const FOLDER_FOLDER_TABLE_SCROLL_INTO_VIEW_ID = + "folder-folder-table-scroll-anchor"; + +function formatFolderEnumTableFilter(metricKey: string, valueLabel: string): string { + const escaped = valueLabel.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return `${metricKey}=="${escaped}"`; +} + +export default function FolderTable({ + dataset, + game, + folderBreakdownEnumTablePreset = null, +}: { + dataset: FolderDataset; + game: V3Game; + folderBreakdownEnumTablePreset?: FolderEnumBreakdownTablePreset | null; +}) { const defaultRating = useScoreRatingAlg(game); const preferredRanking = usePreferredRanking(); @@ -34,8 +58,26 @@ export default function FolderTable({ dataset, game }: { dataset: FolderDataset; ); const [usePaginatedView, setUsePaginatedView] = useState(false); + const { + breakpoint: { isLg }, + } = useContext(WindowContext); + const folderSearchFunctions = useMemo(() => CreateDefaultFolderSearchParams(game), [game]); + const externalPreset = useMemo(() => { + if (!folderBreakdownEnumTablePreset || !isLg) { + return null; + } + + return { + search: formatFolderEnumTableFilter( + folderBreakdownEnumTablePreset.metricKey, + folderBreakdownEnumTablePreset.valueLabel, + ), + nonce: folderBreakdownEnumTablePreset.nonce, + }; + }, [folderBreakdownEnumTablePreset, isLg]); + const headers: Header[] = [ ChartHeader(game, (k) => k), FolderIndicatorHeader, @@ -58,7 +100,7 @@ export default function FolderTable({ dataset, game }: { dataset: FolderDataset; ); return ( - <> +

@@ -79,6 +121,7 @@ export default function FolderTable({ dataset, game }: { dataset: FolderDataset; d.chartID} searchFunctions={folderSearchFunctions} /> - +

); } diff --git a/typescript/client/src/types/api-returns.ts b/typescript/client/src/types/api-returns.ts index da3b607d4..6b486aabb 100644 --- a/typescript/client/src/types/api-returns.ts +++ b/typescript/client/src/types/api-returns.ts @@ -135,6 +135,38 @@ export interface UGPTTableReturns { table: TableDocument; } +/** Rows returned by `GET .../tables/:tableID/evolution`. */ +export interface TableEvolutionEventAPI { + chartID: string; + enumIndex: integer; + metric: string; + scoreID: string; + timeAchieved: number | null; + timeAdded: number; + value: string; +} + +export interface UGPTTableEvolutionReturns { + charts: ChartDocument[]; + events: TableEvolutionEventAPI[]; + folderChartIDs: Record; + folders: FolderDocument[]; + songs: SongDocument[]; + table: TableDocument; +} + +/** Same event/chart payloads as table evolution; scoped to a single folder. */ +export interface UGPTFolderEvolutionReturns { + charts: ChartDocument[]; + events: TableEvolutionEventAPI[]; + folder: FolderDocument; + folderChartIDs: Record; + folders: FolderDocument[]; + songs: SongDocument[]; +} + +export type UGPTEvolutionReplayReturns = UGPTTableEvolutionReturns | UGPTFolderEvolutionReturns; + export interface UGPTFolderReturns { folder: FolderDocument; songs: SongDocument[]; @@ -142,6 +174,12 @@ export interface UGPTFolderReturns { pbs: PBScoreDocument[]; } +/** `GET .../folders/:folderSlug/stats`. */ +export interface UGPTFolderSlugStatsReturns { + folder: FolderDocument; + stats: FolderStatsInfo | null; +} + export interface GPTFolderReturns { folder: FolderDocument; songs: SongDocument[]; diff --git a/typescript/client/src/util/time.ts b/typescript/client/src/util/time.ts index 5634ac410..a04e2b592 100644 --- a/typescript/client/src/util/time.ts +++ b/typescript/client/src/util/time.ts @@ -13,6 +13,11 @@ export function FormatDate(ms: number) { return DateTime.fromMillis(ms).toLocaleString(DateTime.DATE_HUGE); } +/** Local time of day only; use with {@link FormatDate} when the calendar date is shown separately. */ +export function FormatTimeOfDay(ms: number) { + return DateTime.fromMillis(ms).toLocaleString(DateTime.TIME_SIMPLE); +} + export function FormatDuration(ms: number) { return humaniseDuration(ms, { units: ["d", "h", "m"], diff --git a/typescript/server/src/lib/folders/table-evolution.ts b/typescript/server/src/lib/folders/table-evolution.ts new file mode 100644 index 000000000..6779ca657 --- /dev/null +++ b/typescript/server/src/lib/folders/table-evolution.ts @@ -0,0 +1,200 @@ +import { SELECT_CHART, ToChartDocument } from "#lib/db-formats/chart"; +import { GetSongsByIDs } from "#lib/db-formats/song"; +import DB from "#services/pg/db"; +import { GetScoresForTableEvolution } from "#utils/queries/scores"; +import { + GetGameConfig, + GetScoreEnumConfs, + type integer, + type ChartDocument, + type FolderDocument, + type ScoreDocument, + type SongDocument, + type TableDocument, + type V3Game, +} from "tachi-common"; + +import type { ConfEnumScoreMetric } from "tachi-common/types/metrics"; + +import { GetFolderChartIDs } from "./folders"; + +/** One strict improvement on a `(chart × metric)` enum index within table evolution semantics. */ +export interface TableEvolutionEvent { + chartID: string; + enumIndex: integer; + metric: string; + scoreID: string; + timeAchieved: integer | null; + timeAdded: integer; + value: string; +} + +export async function GetTableEvolutionFolderChartMembership(folders: Array): Promise<{ + distinctChartIDs: Array; + folderChartIDs: Record>; +}> { + const folderChartIDs: Record> = {}; + const distinct = new Set(); + + for (const folder of folders) { + const chartIds = await GetFolderChartIDs(folder.folderID); + + folderChartIDs[folder.slug] = chartIds; + for (const id of chartIds) { + distinct.add(id); + } + } + + return { distinctChartIDs: [...distinct], folderChartIDs }; +} + +export async function LoadEvolutionChartsAndSongs( + chartIds: Array, +): Promise<{ charts: Array; songs: Array }> { + if (chartIds.length === 0) { + return { charts: [], songs: [] }; + } + + const rows = await DB.selectFrom("chart") + .innerJoin("song", "song.id", "chart.song_id") + .select(SELECT_CHART) + .where("chart.id", "in", chartIds) + .execute(); + + const charts = rows.map(ToChartDocument); + const songLegacyIds = [...new Set(charts.map((item) => item.song.id))]; + const songs = await GetSongsByIDs(songLegacyIds); + + return { charts, songs }; +} + +export function ComputeTableEvolutionEvents( + scores: Array, + enumConfs: Record>, +): Array { + const evolutionEvents: Array = []; + + let currentChartID: string | undefined; + const prevMaxForMetric: Record = {}; + + const readEnumIdx = (scoreData: ScoreDocument["scoreData"], metric: string): integer | undefined => { + const fromRoot = scoreData.enumIndexes?.[metric as keyof typeof scoreData.enumIndexes] as + | integer + | undefined; + const fromOptional = + scoreData.optional?.enumIndexes?.[metric as keyof typeof scoreData.optional.enumIndexes]; + + const coerced = typeof fromOptional === "number" ? fromOptional : undefined; + + return fromRoot ?? coerced ?? undefined; + }; + + for (const score of scores) { + if (score.chartID !== currentChartID) { + currentChartID = score.chartID; + for (const key of Object.keys(prevMaxForMetric)) { + delete prevMaxForMetric[key]; + } + } + + for (const [metricName, metricConf] of Object.entries(enumConfs)) { + if (metricConf.type !== "ENUM") { + continue; + } + + const idxRaw = readEnumIdx(score.scoreData, metricName); + if (idxRaw === undefined) { + continue; + } + + const prev = prevMaxForMetric[metricName]; + if (prev !== undefined && idxRaw <= prev) { + continue; + } + + prevMaxForMetric[metricName] = idxRaw; + + const minRelIdx = metricConf.values.indexOf(metricConf.minimumRelevantValue); + if (idxRaw < minRelIdx) { + continue; + } + + const valueLabel = metricConf.values[idxRaw]; + if (valueLabel === undefined) { + continue; + } + + evolutionEvents.push({ + chartID: score.chartID, + enumIndex: idxRaw, + metric: metricName, + scoreID: score.scoreID, + timeAchieved: score.timeAchieved, + timeAdded: score.timeAdded, + value: valueLabel, + }); + } + } + + evolutionEvents.sort((aItem, bItem) => { + const timeCmp = (aItem.timeAchieved ?? 0) - (bItem.timeAchieved ?? 0); + + return timeCmp !== 0 ? timeCmp : aItem.timeAdded - bItem.timeAdded; + }); + + return evolutionEvents; +} + +export async function LoadTableEvolutionPayload( + userID: integer, + game: V3Game, + folders: Array, + table: TableDocument, +): Promise<{ + charts: Array; + events: Array; + folderChartIDs: Record>; + folders: Array; + songs: Array; + table: TableDocument; +}> { + const enumConfs = GetScoreEnumConfs(GetGameConfig(game)); + + const { distinctChartIDs, folderChartIDs } = await GetTableEvolutionFolderChartMembership(folders); + + const scores = await GetScoresForTableEvolution(userID, game, distinctChartIDs); + const events = ComputeTableEvolutionEvents(scores, enumConfs); + const { charts, songs } = await LoadEvolutionChartsAndSongs(distinctChartIDs); + + return { charts, events, folderChartIDs, folders, songs, table }; +} + +export async function LoadFolderEvolutionPayload( + userID: integer, + game: V3Game, + folder: FolderDocument, +): Promise<{ + charts: Array; + events: Array; + folder: FolderDocument; + folderChartIDs: Record>; + folders: Array; + songs: Array; +}> { + const enumConfs = GetScoreEnumConfs(GetGameConfig(game)); + + const { distinctChartIDs, folderChartIDs } = await GetTableEvolutionFolderChartMembership([folder]); + + const scores = await GetScoresForTableEvolution(userID, game, distinctChartIDs); + const events = ComputeTableEvolutionEvents(scores, enumConfs); + const { charts, songs } = await LoadEvolutionChartsAndSongs(distinctChartIDs); + + return { + charts, + events, + folder, + folderChartIDs, + folders: [folder], + songs, + }; +} diff --git a/typescript/server/src/server/router/api/v1/spec.ts b/typescript/server/src/server/router/api/v1/spec.ts index ba93814bb..778266788 100644 --- a/typescript/server/src/server/router/api/v1/spec.ts +++ b/typescript/server/src/server/router/api/v1/spec.ts @@ -913,6 +913,30 @@ export const API_V1_SPEC = { }), }, + "GET /users/:userID/games/:game/tables/:tableID/evolution": { + description: + "Table evolution: strict per-chart enum milestones at or above each metric minimumRelevantValue; events sorted by play time.", + input: z.object({}), + output: z.strictObject({ + charts: docArray(), + events: z.array( + z.strictObject({ + chartID: z.string(), + enumIndex: z.number(), + metric: z.string(), + scoreID: z.string(), + timeAchieved: z.number().nullable(), + timeAdded: z.number(), + value: z.string(), + }), + ), + folderChartIDs: z.record(z.string(), z.array(z.string())), + folders: docArray(), + songs: docArray(), + table: doc(), + }), + }, + // ──────────────────────────────────────────────── // UGPT Showcase // ──────────────────────────────────────────────── @@ -1031,6 +1055,30 @@ export const API_V1_SPEC = { }), }, + "GET /users/:userID/games/:game/folders/:folderSlug/evolution": { + description: + "Folder evolution: strict per-chart enum milestones at or above each metric minimumRelevantValue; events sorted by play time.", + input: z.object({}), + output: z.strictObject({ + charts: docArray(), + events: z.array( + z.strictObject({ + chartID: z.string(), + enumIndex: z.number(), + metric: z.string(), + scoreID: z.string(), + timeAchieved: z.number().nullable(), + timeAdded: z.number(), + value: z.string(), + }), + ), + folder: doc(), + folderChartIDs: z.record(z.string(), z.array(z.string())), + folders: docArray(), + songs: docArray(), + }), + }, + // ──────────────────────────────────────────────── // UGPT Targets // ──────────────────────────────────────────────── diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.test.ts index 92518bb82..8739fafdf 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.test.ts @@ -242,3 +242,83 @@ describe("GET /api/v1/users/:userID/games/:game/folders/:folderID/timeline", () expect(res.status).toBe(400); }); }); + +describe("GET /api/v1/users/:userID/games/:game/folders/:folderSlug/evolution", () => { + it("returns lamp milestones chronologically above minimum relevance", async () => { + const { id: userId } = await seedUser({ username: `folder_evo_${Date.now()}` }); + await seedIidxSpProfile(userId); + + const folderId = `folder_evo_${Date.now()}`; + const { chartA } = await seedFolderWithThreeCharts({ + folderId, + folderLegacy: `${folderId}_leg`, + }); + + const uid = `${Date.now()}_${Math.floor(Math.random() * 100_000)}`; + + async function insScore(scoreId: string, lamp: string, iso: string) { + const { data, derived, judgements } = mongoScoreDataToPg( + "iidx-sp", + { + score: 1400, + lamp, + percent: 93, + optional: {}, + judgements: { pgreat: 1, great: 0 }, + grade: "AAA", + } as ScoreData<"iidx-sp">, + ); + + await DB.insertInto("score") + .values({ + id: scoreId, + user_id: userId, + chart_id: chartA, + game: "iidx-sp", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: iso, + time_added: iso, + highlight: false, + comment: null, + }) + .execute(); + } + + await insScore(`sc_${uid}_1`, "FAILED", "2020-01-01T10:00:00.000Z"); + await insScore(`sc_${uid}_2`, "EASY CLEAR", "2020-02-01T10:00:00.000Z"); + await insScore(`sc_${uid}_3`, "HARD CLEAR", "2020-03-01T10:00:00.000Z"); + + const res = await mockApi.get( + `/api/v1/users/${userId}/games/iidx-sp/folders/${folderId}/evolution`, + ); + + expect(res.status).toBe(200); + expect(res.body.body.folder.slug).toBe(folderId); + + const { events } = res.body.body; + const lampEvents = events.filter((e: { metric: string }) => e.metric === "lamp"); + + expect(lampEvents.length).toBe(2); + expect(lampEvents[0].value).toBe("EASY CLEAR"); + expect(lampEvents[1].value).toBe("HARD CLEAR"); + + expect(res.body.body.folderChartIDs[folderId]).toContain(chartA); + }); + + it("returns 404 when the folder does not exist", async () => { + const { id: userId } = await seedUser({ username: `folder_evo_404_${Date.now()}` }); + await seedIidxSpProfile(userId); + + const res = await mockApi.get( + `/api/v1/users/${userId}/games/iidx-sp/folders/ghost_folder_xyz/evolution`, + ); + + expect(res.status).toBe(404); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.ts index dc9d1bfd5..35a71ce7e 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderSlug/router.ts @@ -4,6 +4,7 @@ import { GetFolderChartsAndSongs, GetPBsOnFolder, } from "#lib/folders/folders"; +import { LoadFolderEvolutionPayload } from "#lib/folders/table-evolution"; import { withSelf, withUserGameProfile } from "#lib/router/middleware"; import { success } from "#lib/router/typed-router"; import { API_V1_ROUTER } from "#server/router/api/v1/router"; @@ -166,3 +167,30 @@ API_V1_ROUTER.add( }); }, ); + +/** + * **Folder evolution:** distinct enum milestones (per metric, per chart) at or above each metric's + * `minimumRelevantValue`, scoped to charts in this folder — same semantics as table evolution. + * + * @name GET /api/v1/users/:userID/games/:game/folders/:folderSlug/evolution + */ +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/folders/:folderSlug/evolution", + withUserGameProfile, + async ({ ctx, params }) => { + const { requestedUser: user, game } = ctx; + + const folder = await LoadFolderDocumentByGameAndSlug(game, params.folderSlug); + + if (!folder || folder.game !== game) { + throw new ExpectedErr(404, "This folder does not exist."); + } + + const body = await LoadFolderEvolutionPayload(user.id, game, folder); + + return success( + `Returned ${body.events.length} folder evolution events for ${folder.title}.`, + body, + ); + }, +); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts index 00794c93f..471e357a3 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.test.ts @@ -76,6 +76,13 @@ describe("GET /api/v1/users/:userID/games/:game/tables/:tableID", () => { .values({ table_id: tableUuid, folder_id: folderId, ordering: 0 }) .execute(); + await DB.insertInto("folder_chart_lookup") + .values({ + folder_id: folderId, + chart_id: chartId, + }) + .execute(); + await DB.insertInto("game_profile") .values({ user_id: 1, @@ -124,4 +131,76 @@ describe("GET /api/v1/users/:userID/games/:game/tables/:tableID", () => { expect(res.status).toBe(404); }); + + describe("GET /evolution", () => { + beforeEach(async () => { + const chartId = TestingIIDXSPScorePB.chartID; + const uid = `${Date.now()}_${Math.floor(Math.random() * 100_000)}`; + + async function insScore(scoreId: string, lamp: string, iso: string) { + const { data, derived, judgements } = mongoScoreDataToPg( + "iidx-sp", + { + score: 1400, + lamp, + percent: 93, + optional: {}, + judgements: { pgreat: 1, great: 0 }, + grade: "AAA", + } as ScoreData<"iidx-sp">, + ); + + await DB.insertInto("score") + .values({ + id: scoreId, + user_id: 1, + chart_id: chartId, + game: "iidx-sp", + session_id: null, + import_id: null, + data: JSON.stringify(data), + derived_data: JSON.stringify(derived), + judgements: JSON.stringify(judgements), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: iso, + time_added: iso, + highlight: false, + comment: null, + }) + .execute(); + } + + await insScore(`sc_${uid}_1`, "FAILED", "2020-01-01T10:00:00.000Z"); + await insScore(`sc_${uid}_2`, "EASY CLEAR", "2020-02-01T10:00:00.000Z"); + await insScore(`sc_${uid}_3`, "HARD CLEAR", "2020-03-01T10:00:00.000Z"); + }); + + it("returns lamp milestones chronologically above minimum relevance", async () => { + const res = await mockApi.get( + "/api/v1/users/1/games/iidx-sp/tables/mock_table/evolution", + ); + + expect(res.status).toBe(200); + + const { events } = res.body.body; + const lampEvents = events.filter((e: { metric: string }) => e.metric === "lamp"); + + expect(lampEvents.length).toBe(2); + expect(lampEvents[0].value).toBe("EASY CLEAR"); + expect(lampEvents[1].value).toBe("HARD CLEAR"); + + expect(res.body.body.folderChartIDs["ugtfolder"]).toContain( + TestingIIDXSPScorePB.chartID, + ); + }); + + it("returns 404 when the table does not exist", async () => { + const res = await mockApi.get( + "/api/v1/users/1/games/iidx-sp/tables/ghost_table_xyz/evolution", + ); + + expect(res.status).toBe(404); + }); + }); }); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts index 267d4d20d..01a881e3f 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/tables/router.ts @@ -1,5 +1,6 @@ import { LoadTableDocumentByLegacyIdForGame } from "#lib/db-formats/table"; import { GetEnumDistForFolders, GetFoldersFromTable } from "#lib/folders/folders"; +import { LoadTableEvolutionPayload } from "#lib/folders/table-evolution"; import { withUserGameProfile } from "#lib/router/middleware"; import { success } from "#lib/router/typed-router"; import { API_V1_ROUTER } from "#server/router/api/v1/router"; @@ -28,3 +29,31 @@ API_V1_ROUTER.add( return success(`Returned stats for ${folders.length} folders.`, { folders, stats, table }); }, ); + +/** + * **Table evolution:** distinct enum milestones (per metric, per chart) at or above {@link ConfEnumScoreMetric.minimumRelevantValue}, + * scoped to charts in folders attached to this table. + * + * @name GET /api/v1/users/:userID/games/:game/tables/:tableID/evolution + */ +API_V1_ROUTER.add( + "GET /users/:userID/games/:game/tables/:tableID/evolution", + withUserGameProfile, + async ({ ctx, params }) => { + const { requestedUser: user, game } = ctx; + + const table = await LoadTableDocumentByLegacyIdForGame(params.tableID, game); + + if (!table) { + throw new ExpectedErr(404, `No table with ID ${params.tableID} exists.`); + } + + const folders = await GetFoldersFromTable(table); + const body = await LoadTableEvolutionPayload(user.id, game, folders, table); + + return success( + `Returned ${body.events.length} table evolution events for ${folders.length} folders.`, + body, + ); + }, +); diff --git a/typescript/server/src/utils/queries/scores.ts b/typescript/server/src/utils/queries/scores.ts index 19ac37c41..6476de0a0 100644 --- a/typescript/server/src/utils/queries/scores.ts +++ b/typescript/server/src/utils/queries/scores.ts @@ -140,3 +140,29 @@ export async function GetFolderTimelineScores( return scores; } + +/** + * Full score history on the charts, ordered like {@link GetFolderTimelineScores}: by `chart.id`, + * then `time_achieved` ascending (unknown play time sorts last via infinity), then `time_added`. + * Used by table evolution to detect strict PB increases per `(chart × metric)` in chronological order. + */ +export async function GetScoresForTableEvolution( + userID: integer, + game: V3Game, + chartIDs: string[], +): Promise> { + if (chartIDs.length === 0) { + return []; + } + + const rows = await scoreDocumentJoin() + .where("score.user_id", "=", userID) + .where("score.game", "=", game) + .where("chart.id", "in", chartIDs) + .orderBy("chart.id") + .orderBy(sql`coalesce(score.time_achieved, 'infinity'::timestamptz) asc`) + .orderBy("score.time_added", "asc") + .execute(); + + return rows.map((r) => ToScoreDocument(r as ScoreDocumentJoinRow)); +}