feat: masterpiece

This commit is contained in:
zk
2026-05-11 21:58:35 +00:00
parent 1884a0ca7d
commit e15f52bbe7
30 changed files with 3135 additions and 795 deletions
@@ -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;
}
}
@@ -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 = (
<div className="p-3">
<div className="row g-3 align-items-start">
<div className="col-12 col-lg-8 order-2 order-lg-1 min-w-0">
<div className="fs-6 lh-sm mb-2">
{song && chart ? (
<>
<span className="fw-semibold text-body-emphasis">{song.title}</span>
<span className="text-body-secondary fw-normal ms-2 small text-nowrap">
({FormatDifficultyShort(chart)})
</span>
</>
) : chart ? (
<>
<span className="fst-italic text-body-secondary">Unknown chart</span>
<span className="text-body-secondary fw-normal ms-2 small text-nowrap">
({FormatDifficultyShort(chart)})
</span>
</>
) : (
<span className="fst-italic text-body-secondary">Unknown chart</span>
)}
</div>
<div className="d-flex align-items-center flex-wrap gap-2 border-secondary border-opacity-25 border-top pt-2">
<small className="text-body-secondary lh-1 mb-0 text-uppercase">
{UppercaseFirst(evRow.metric)}
</small>
<span
className="fw-semibold lh-sm px-2 py-1 rounded-pill border small shadow-sm"
style={{
borderColor: chipFill ?? "var(--bs-secondary)",
backgroundColor: chipBg ?? "var(--bs-body-bg)",
}}
>
{evRow.value}
</span>
</div>
</div>
<div className="col-12 col-lg-4 order-1 order-lg-2">
<div
className="bg-body-tertiary bg-opacity-50 border border-secondary border-opacity-25 lh-sm ms-lg-auto px-3 py-2 rounded-3 tabular-nums text-lg-end w-100"
style={{
maxWidth: "22rem",
}}
>
<div className="fs-6 fw-semibold mb-1 text-body-emphasis lh-sm">
{MillisToSince(timeMs)}
</div>
<div className="mt-1">
<Muted>{FormatTime(timeMs)}</Muted>
</div>
</div>
</div>
</div>
</div>
);
return (
<Link
className={`${rowStyles.feedRowLink} ${feedRowBaseClass}`}
style={articleStyle}
to={chartHref}
>
{body}
</Link>
);
}
@@ -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;
}
}
@@ -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<string, Record<string, string>> | 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<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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(
<rect
fill={colour}
height={Math.max(segH, 0.15)}
key={`b-${bi}-e-${ej}`}
opacity={0.9}
width={barW}
x={bi + inset}
y={yTop}
/>,
);
yBottom = yTop;
}
}
return (
<g aria-hidden>
<rect
fill="var(--bs-secondary-bg)"
height={PLOT_BOTTOM - PLOT_TOP}
opacity={0.65}
rx={1}
stroke="var(--bs-border-color)"
strokeOpacity={0.4}
strokeWidth={0.12}
width={bucketW}
x={0}
y={PLOT_TOP}
/>
{plots}
</g>
);
}, [allEnumColours, axisSpan, enumMetric, gameConfig, minAxis, workingEvents]);
const interactive = workingEvents.length > 0;
return (
<>
<p className="small lh-sm mx-auto mb-3 text-body-secondary text-center px-2">
<strong>Drag or click</strong> the chart to move in time - use arrow keys when the
chart has focus for keyboard scrubbing.
</p>
<div
aria-disabled={!interactive}
aria-labelledby={rangeId}
aria-valuemax={maxAxis}
aria-valuemin={minAxis}
aria-valuenow={Math.round(scrubClampedMs)}
aria-valuetext={`Replay time ${FormatDate(scrubClampedMs)}`}
className={`${timelineStyles.track} position-relative rounded-4 overflow-hidden shadow-sm border border-primary border-opacity-25 bg-body-secondary bg-opacity-10 w-100 ${
interactive ? "user-select-none" : "opacity-75 pe-none"
}`}
onKeyDown={onSliderKeyDown}
onPointerCancel={releasePointerCaptureIfAny}
onPointerDown={onPointerDownTrack}
onPointerMove={onPointerMoveTrack}
onPointerUp={releasePointerCaptureIfAny}
ref={trackRef}
role={interactive ? "slider" : undefined}
style={{
cursor: interactive ? "grab" : "not-allowed",
height: GRAPH_CSS_HEIGHT_PX,
touchAction: interactive ? "none" : undefined,
}}
tabIndex={interactive ? 0 : -1}
>
<svg
aria-hidden
className="d-block h-100 w-100 bg-transparent"
height="100%"
preserveAspectRatio="none"
viewBox={`0 0 ${TIMELINE_BIN_COUNT} ${VIEW_BOX_H}`}
width="100%"
>
<title>Milestone density (decorative).</title>
{graphBody}
</svg>
{interactive ? (
<div
aria-hidden
className="bg-body-emphasis opacity-85 position-absolute rounded-pill shadow"
style={{
bottom: "6%",
left: `${playheadLeftPct}%`,
pointerEvents: "none",
top: "8%",
transform: "translateX(-50%)",
width: "4px",
zIndex: 2,
}}
/>
) : null}
</div>
</>
);
}
@@ -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%;
}
@@ -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<string, string> | 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 <small className="text-body-secondary">No charts in this folder.</small>;
}
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 ? (
<div
aria-hidden
className="position-absolute start-0 top-0 bottom-0"
style={{
backgroundColor: chrome.fillStrong,
transition: "width 0.35s ease-out",
width: pctWidth,
}}
/>
) : null}
<div className="align-items-center position-relative z-1 d-flex flex-wrap gap-2 justify-content-between px-3 py-2">
<span className="fw-semibold lh-sm mb-0 pe-2 text-body" title={label}>
<span
aria-hidden
className="d-inline-block me-2 rounded-circle"
style={{
backgroundColor: chipFill ?? "var(--bs-secondary)",
flexShrink: 0,
height: "0.5rem",
verticalAlign: "middle",
width: "0.5rem",
}}
/>
{label}
</span>
<span className="ms-auto tabular-nums text-nowrap">
<span className="fw-bold fs-6 text-body-emphasis">{count}</span>
<span className="ms-2 small text-body-secondary">
({ToFixedFloor(pct, 1)}%)
</span>
</span>
</div>
</>
);
rows.push(
clickableRow && onEnumBreakdownRowClick ? (
<button
aria-label={`Show charts filtered to ${enumMetric}: ${label}`}
className={`position-relative overflow-hidden shadow-sm text-start border-0 p-0 w-100 text-body ${rowHoverChromeClass}`}
key={label}
onClick={() => {
onEnumBreakdownRowClick(label);
}}
style={rowShellStyle}
type="button"
>
{rowInterior}
</button>
) : (
<div
className={`position-relative overflow-hidden shadow-sm ${rowHoverChromeClass}`}
key={label}
style={rowShellStyle}
>
{rowInterior}
</div>
),
);
}
const remainder = Math.max(0, total - filled);
const remainderPct = total > 0 ? (100 * remainder) / total : 0;
return (
<div className={selected ? "border border-primary border-opacity-75 pb-3 pe-2 pt-2 ps-2 rounded-4 shadow-sm bg-primary bg-opacity-10" : undefined}>
<div
className={suppressTopRule ? undefined : "mt-3 border-secondary border-opacity-25 border-top pt-3"}
>
<div className="align-items-center d-flex flex-wrap gap-2 justify-content-between mb-2">
{onActivate ? (
<button
className={`btn btn-sm fw-semibold text-uppercase ${selected ? "btn-primary" : "btn-outline-secondary"}`}
onClick={onActivate}
style={{ letterSpacing: "0.06em" }}
type="button"
>
{UppercaseFirst(enumMetric)}
{selected ? (
<span className="fst-normal ms-2 fw-normal text-white-50 text-capitalize">
(for replay)
</span>
) : null}
</button>
) : (
<div
className="fw-semibold lh-sm small text-body-secondary text-uppercase"
style={{ letterSpacing: "0.06em" }}
>
{UppercaseFirst(enumMetric)} breakdown
</div>
)}
</div>
<div className="d-flex flex-column gap-2">{rows}</div>
{remainder > 0 ? (
<div
className="position-relative overflow-hidden mt-2"
style={{
backgroundColor:
"color-mix(in srgb, var(--bs-secondary) 14%, transparent)",
border: "1px dashed var(--bs-border-color-translucent)",
borderRadius: "var(--bs-border-radius-lg)",
}}
>
<div
aria-hidden
className="position-absolute start-0 top-0 bottom-0"
style={{
backgroundColor:
"color-mix(in srgb, var(--bs-secondary) 40%, transparent)",
transition: "width 0.35s ease-out",
width: `${ToFixedFloor(Math.min(100, Math.max(0, remainderPct)), 3)}%`,
}}
/>
<div className="position-relative z-1 align-items-center d-flex flex-wrap gap-2 justify-content-between px-3 py-2 small text-body-secondary">
<span className="fst-italic fw-semibold">{remainderLine}</span>
<span className="ms-auto tabular-nums text-nowrap">
<span className="fw-bold text-body-emphasis">{remainder}</span>
<span className="ms-2 small text-body-secondary">
({ToFixedFloor(remainderPct, 1)}%)
</span>
</span>
</div>
</div>
) : null}
</div>
</div>
);
}
@@ -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;
}
@@ -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<string, string> | undefined;
enumMetric: string;
gameConfig: GameConfig;
stats: FolderStatsInfo;
}) {
const barRef = useRef<HTMLDivElement>(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 <Muted>—</Muted>;
}
const remainder = Math.max(0, total - filled);
const remainderWidthPct = total > 0 ? (100 * remainder) / total : 0;
return (
<div
className={`border overflow-hidden rounded-3 ${barStyles.folderEnumBar} ${animateSegments ? barStyles.folderEnumBarAnimated : ""}`}
dir="ltr"
lang="en"
ref={barRef}
style={{
backgroundColor: "var(--bs-secondary-bg)",
direction: "ltr",
display: "block",
height: FOLDER_ENUM_BAR_HEIGHT,
minHeight: "1.25rem",
unicodeBidi: "isolate",
width: "100%",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
height: "100%",
width: "100%",
}}
>
{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 (
<QuickTooltip
key={seg.label}
tooltipContent={
<>
<div>{seg.label}</div>
<div>{ToFixedFloor(pct, 2)}%</div>
<Muted>
({seg.count} / {total})
</Muted>
</>
}
>
<div
className={`h-100 ${barStyles.folderEnumBarSegment} ${animateSegments ? barStyles.folderEnumBarSegmentAnimated : ""}`}
style={{
...(animateSegments
? { animationDelay: `${0.05 + segIndex * 0.055}s` }
: {}),
backgroundColor: fill,
flex: "none",
minWidth: seg.count ? "4px" : 0,
width: `${pct}%`,
}}
>
{showInlinePct && (
<span className={barStyles.folderEnumBarLabel}>
{inlinePctText}
</span>
)}
</div>
</QuickTooltip>
);
})}
{remainderWidthPct > 0 && (
<div
aria-hidden
className={`h-100 ${barStyles.folderEnumBarRemainder} ${animateSegments ? barStyles.folderEnumBarRemainderAnimated : ""}`}
style={{
...(animateSegments
? {
animationDelay: `${0.05 + segments.length * 0.055}s`,
}
: {}),
backgroundColor: "transparent",
flex: "none",
width: `${remainderWidthPct}%`,
}}
/>
)}
</div>
</div>
);
}
@@ -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);
}
}
@@ -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<TableDocument[]>(`/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 (
<>
<InputGroup size="lg">
<InputGroup.Text>Table</InputGroup.Text>
<Form.Select onChange={(e) => setTableID(e.target.value)} value={tableID}>
{displayableTables.map((e) => (
<option key={e.tableID} value={e.tableID}>
{e.title}
</option>
))}
</Form.Select>
</InputGroup>
<Divider />
{table && <TableFolderViewer {...{ reqUser, game, table }} />}
</>
const tablePicker = (
<InputGroup size="lg">
<InputGroup.Text>Table</InputGroup.Text>
<Form.Select onChange={(e) => onTablePickChange(e.target.value)} value={tableID}>
{displayableTables.map((e) => (
<option key={e.tableID} value={e.tableID}>
{e.title}
</option>
))}
</Form.Select>
</InputGroup>
);
}
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<string, string> | undefined;
enumMetric: string;
gameConfig: GameConfig;
stats: FolderStatsInfo;
}) {
const barRef = useRef<HTMLDivElement>(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 <Muted>—</Muted>;
}
const remainder = Math.max(0, total - filled);
const remainderWidthPct = total > 0 ? (100 * remainder) / total : 0;
return (
<div
className={`border overflow-hidden rounded-3 ${folderTableStyles.folderEnumBar}`}
dir="ltr"
lang="en"
ref={barRef}
style={{
backgroundColor: "var(--bs-secondary-bg)",
direction: "ltr",
display: "block",
height: FOLDER_ENUM_BAR_HEIGHT,
minHeight: "1.25rem",
unicodeBidi: "isolate",
width: "100%",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
height: "100%",
width: "100%",
}}
>
{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 (
<QuickTooltip
key={seg.label}
tooltipContent={
<>
<div>{seg.label}</div>
<div>{ToFixedFloor(pct, 2)}%</div>
<Muted>
({seg.count} / {total})
</Muted>
</>
}
>
<div
className={`h-100 ${folderTableStyles.folderEnumBarSegment}`}
style={{
animationDelay: `${0.05 + segIndex * 0.055}s`,
backgroundColor: fill,
flex: "none",
minWidth: seg.count ? "4px" : 0,
width: `${pct}%`,
}}
>
{showInlinePct && (
<span className={folderTableStyles.folderEnumBarLabel}>
{inlinePctText}
</span>
)}
</div>
</QuickTooltip>
);
})}
{remainderWidthPct > 0 && (
<div
aria-hidden
className={`h-100 ${folderTableStyles.folderEnumBarRemainder}`}
style={{
animationDelay: `${0.05 + segments.length * 0.055}s`,
backgroundColor: "transparent",
flex: "none",
width: `${remainderWidthPct}%`,
}}
/>
)}
</div>
</div>
);
}
function TableFolderViewer({ reqUser, game, table }: { table: TableDocument } & Props) {
const { data, error } = useApiQuery<UGPTTableReturns>(
`/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<Map<string, UGPTFolderStats>>(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 <ApiError error={error} />;
}
if (!data || !hasLoadedFolderMap) {
return <Loading />;
}
return (
<>
<Card header="Overview">
<div className="row">
<div className="col-12 d-flex justify-content-center">
<div className="btn-group">
{GetScoreMetrics(gameConfig, "ENUM").map((e) => (
<SelectButton
id={e}
key={e}
setValue={setEnumMetric}
value={enumMetric}
>
{/* @ts-expect-error enum icon keys align with score metrics */}
<Icon type={gptImpl.enumIcons[e]} /> {UppercaseFirst(e)}s
</SelectButton>
))}
</div>
</div>
</div>
</Card>
<Divider />
<TableFolderList
dataMap={dataMap}
enumMetric={enumMetric}
game={game}
reqUser={reqUser}
table={table}
/>
</>
);
}
function TableFolderList({
table,
dataMap,
enumMetric,
reqUser,
game,
}: {
dataMap: Map<string, UGPTFolderStats>;
enumMetric: string;
table: TableDocument;
} & Props) {
const gameConfig = useMemo(() => GetGameConfig(game), [game]);
const enumColours = useMemo(
() =>
(
GPT_CLIENT_IMPLEMENTATIONS[game].enumColours as
| Record<string, Record<string, string>>
| 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 (
<div className="mt-4">
{dataset.length === 0 ? (
<div className="text-center text-body-secondary py-5">No folders.</div>
) : (
dataset.map((data) => (
<Link
className={`${folderTableStyles.folderRow} bg-body-tertiary bg-opacity-25`}
key={data.folder.slug}
{activeFolderSlug ? (
<div>
<Button
aria-controls={overviewPanelId}
aria-expanded={tableOverviewOpen}
className={`align-items-center d-flex fw-semibold gap-2 px-4 py-3 shadow-sm text-wrap justify-content-between w-100 ${!tableOverviewOpen ? "border-0" : ""}`}
onClick={() => {
if (user?.id === reqUser.id) {
APIFetchV1(
`/users/${reqUser.id}/games/${game}/folders/${data.folder.slug}/viewed`,
{
method: "POST",
},
);
}
setTableOverviewOpen((prev) => !prev);
}}
to={`/u/${reqUser.username}/games/${game}/folders/${data.folder.slug}`}
type="button"
variant={tableOverviewOpen ? "outline-primary" : "primary"}
>
<div className="d-flex flex-column flex-lg-row align-items-lg-center gap-2 gap-lg-3">
<div
className={`fw-semibold text-truncate ${folderTableStyles.folderRowTitle}`}
>
{data.folder.title}
</div>
<div className="flex-grow-1 min-w-0" style={{ minWidth: "10rem" }}>
<FolderEnumProgressBar
colours={enumColours}
enumMetric={enumMetric}
gameConfig={gameConfig}
key={`${enumMetric}-${data.folder.slug}`}
stats={data.stats}
<span>{tableOverviewOpen ? "Hide all folders" : "Show all folders"}</span>
<Icon
aria-hidden
className={tableOverviewOpen ? undefined : "opacity-75"}
type={tableOverviewOpen ? "chevron-up" : "chevron-down"}
/>
</Button>
<Collapse in={tableOverviewOpen}>
<div className="pt-3" id={overviewPanelId}>
{tablePicker}
<Divider className="my-3" />
{table ? (
<TableFolderViewer
game={game}
highlightFolderSlug={activeFolderSlug}
highlightRevealKey={highlightRevealKey}
onFolderRowNavigate={collapseTableOverview}
reqUser={reqUser}
table={table}
/>
</div>
<div
className={`d-flex align-items-center ms-lg-auto ${folderTableStyles.folderRowChartMeta}`}
>
<span
className={`text-body-secondary ${folderTableStyles.folderRowChartCount}`}
>
{data.stats.chartCount}
</span>
<Icon
aria-hidden
className="text-body-secondary opacity-75"
type="chevron-right"
/>
</div>
) : null}
</div>
</Link>
))
</Collapse>
</div>
) : (
<>
{tablePicker}
<Divider />
{table ? <TableFolderViewer {...{ reqUser, game, table }} /> : null}
</>
)}
</div>
</>
);
}
@@ -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) {
<div className="row">
<div className="col-12">
<Switch>
<Route exact path="/u/:userID/games/:game/folders">
<FolderTablePage {...{ reqUser, game }} />
</Route>
<Route
exact
path="/u/:userID/games/:game/folders/search"
@@ -45,8 +43,16 @@ export default function FoldersMainPage({ reqUser, game }: UGPT) {
/>
)}
/>
<Route path="/u/:userID/games/:game/folders/:folderSlug">
<SpecificFolderPage {...{ reqUser, game }} />
<Route path="/u/:userID/games/:game/folders">
<>
<FolderTablePage {...{ reqUser, game }} />
<Route path="/u/:userID/games/:game/folders/:folderSlug">
<>
<Divider className="border-2 mb-4 mt-5" />
<SpecificFolderPage {...{ reqUser, game }} />
</>
</Route>
</>
</Route>
</Switch>
</div>
@@ -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<FolderEnumBreakdownTablePreset | null>(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 (
<FolderInfoHeader
folderDataset={folderDataset}
folderSlug={folderSlug}
folderTitle={data.folder.title}
game={game}
onBreakdownEnumValueClick={onBreakdownEnumValueClick}
reqUser={reqUser}
/>
);
}, [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) {
<Icon type="sort-alpha-up" /> Tierlist View
</SelectLinkButton>
)}
<SelectLinkButton className="text-wrap" to={`${base}/timeline`}>
<Icon type="stream" /> Timeline View
</SelectLinkButton>
<SelectLinkButton className="text-wrap" to={`${base}/compare`}>
<Icon type="users" /> Compare Against User
</SelectLinkButton>
@@ -163,7 +180,13 @@ export default function SpecificFolderPage({ reqUser, game }: Props) {
<div className="col-12">
<Switch>
<Route exact path={base}>
<FolderTable dataset={folderDataset} game={game} />
<FolderNormalView
data={data}
folderDataset={folderDataset}
folderTableEnumPreset={folderTableEnumPreset}
game={game}
reqUser={reqUser}
/>
</Route>
<Route exact path={`${base}/tierlist`}>
<TierlistBreakdown
@@ -174,7 +197,7 @@ export default function SpecificFolderPage({ reqUser, game }: Props) {
/>
</Route>
<Route exact path={`${base}/timeline`}>
<TimelineView folderSlug={folderSlug} game={game} reqUser={reqUser} />
<Redirect to={base} />
</Route>
<Route exact path={`${base}/compare`}>
<FolderComparePage folder={data.folder} game={game} reqUser={reqUser} />
@@ -188,247 +211,42 @@ export default function SpecificFolderPage({ reqUser, game }: Props) {
);
}
/** Total `<td>` 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<string>(gameConfig.preferredDefaultEnum);
const [enumConf, setEnumConf] = useState<ConfEnumScoreMetric<string>>(enumConfs[selectedEnum]!);
const [value, setValue] = useState<string>(enumConf.minimumRelevantValue);
useEffect(() => {
setValue(enumConfs[selectedEnum]!.minimumRelevantValue);
setEnumConf(enumConfs[selectedEnum]!);
}, [selectedEnum]);
return (
<>
<Card cardBodyClassName="vstack gap-4" header="Timeline View">
<h5 className="text-center">
The timeline view shows the order in which you achieved something in a folder!
You can choose the criteria up here.
</h5>
<div className="d-flex flex-column flex-lg-row gap-4">
<Form.Select
onChange={(e) => setSelectedEnum(e.target.value)}
value={selectedEnum}
>
{Object.keys(enumConfs).map((e) => (
<option key={e} value={e}>
{UppercaseFirst(e)}
</option>
))}
</Form.Select>
<Form.Select onChange={(e) => setValue(e.target.value)} value={value}>
{enumConf.values
.slice(enumConf.values.indexOf(enumConf.minimumRelevantValue))
.map((e) => (
<option key={e}>{e}</option>
))}
</Form.Select>
</div>
</Card>
<hr />
<TimelineMain {...{ reqUser, game, folderSlug, enumMetric: selectedEnum, value }} />
</>
);
}
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 <ApiError error={error} />;
}
if (!data) {
return <Loading />;
}
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(
<TimelineDivider
columnCount={columnCount}
key={`folder-timeline-day-${dayNum}`}
>
{FormatDate(scoreData.timeAchieved)}
</TimelineDivider>,
);
}
} else if (!hasHitNulls) {
timelineRows.push(
<TimelineDivider columnCount={columnCount} key="folder-timeline-unknown-time">
Unknown Time
</TimelineDivider>,
);
hasHitNulls = true;
}
timelineRows.push(
<TimelineElement index={index} key={scoreData.scoreID} scoreData={scoreData} />,
);
index++;
}
const emptyTimelineRow =
timelineRows.length === 0 ? (
<tr>
<td
className="py-5 text-center text-body-secondary fst-italic"
colSpan={columnCount}
>
No scores match this folder and criteria yet.
</td>
</tr>
) : null;
return (
<>
<div className="text-center">
<h1 className="display-4">Total Progress</h1>
<h1 className="display-4">
{data.scores.length}
<span className="text-body-secondary" style={{ fontSize: "1.1rem" }}>
/{data.charts.length}
</span>
</h1>
</div>
<TableEvolutionReplay
game={game}
gameConfig={gameConfig}
reqUser={reqUser}
scope={evolutionFolderScope}
/>
<Divider />
<div className="timeline timeline-2 timeline-folder">
<div className="folder-timeline-table-wrap">
<table
className="table table-hover table-sm mb-0 folder-timeline-table text-center"
style={
{
["--folder-timeline-score-cols" as string]:
folderTimelineScoreColVar,
} as CSSProperties
}
>
<tbody>{emptyTimelineRow ?? timelineRows}</tbody>
</table>
</div>
</div>
<Divider />
<div className="text-center">
<h1 className="display-4">Total Progress</h1>
<h1 className="display-4">
{data.scores.length}
<span className="text-body-secondary" style={{ fontSize: "1.1rem" }}>
/{data.charts.length}
</span>
</h1>
</div>
<FolderTable
dataset={folderDataset}
folderBreakdownEnumTablePreset={folderTableEnumPreset}
game={game}
/>
</>
);
}
function TimelineDivider({ children, columnCount }: { children: string; columnCount: integer }) {
return (
<tr className="folder-timeline-date-row">
<td colSpan={columnCount}>
<span className="folder-timeline-date-heading">{children}</span>
</td>
</tr>
);
}
function TimelineElement({
scoreData,
index,
}: {
index: integer;
scoreData: {
__related: {
chart: ChartDocument;
song: SongDocument;
};
} & ScoreDocument;
}) {
return (
<tr className="folder-timeline-score-row timeline-hover">
<td style={{ fontSize: "1.15rem" }}>
<b>#{index}</b>
{Date.now() - scoreData.timeAdded < ONE_DAY && (
<span className="ms-2 label label-inline label-primary fw-bolder">NEW!</span>
)}
</td>
<DifficultyCell alwaysShort chart={scoreData.__related.chart} game={scoreData.game} />
<TitleCell
chart={scoreData.__related.chart}
game={scoreData.game}
song={scoreData.__related.song}
/>
<ScoreCoreCells
chart={scoreData.__related.chart}
game={scoreData.game}
score={scoreData}
/>
<TimestampCell tableFixedLayoutCompat time={scoreData.timeAchieved} />
</tr>
);
}
// here be demons
// i don't remember how this code works
// and i was almost certainly drinking heavily when i wrote it
@@ -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<React.SetStateAction<string>>,
];
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<HTMLDivElement>(null);
const [milestoneFeedFrameHeightPx, setMilestoneFeedFrameHeightPx] = useState(0);
useLayoutEffect(() => {
scrubRef.current = scrubTimeMs;
}, [scrubTimeMs]);
const {
data: evo,
error: evoError,
isLoading: evolutionLoading,
} = useApiQuery<UGPTEvolutionReplayReturns>(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<string, FolderDocument>();
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<string, Record<string, string>>
| 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 (
<section
aria-labelledby={triggerLabelId}
className="border border-secondary border-opacity-50 bg-body-tertiary bg-opacity-10 mb-4 px-3 py-4 rounded-4 shadow-sm"
>
<div className="d-flex justify-content-center px-2">
<Button
aria-controls={collapseId}
aria-expanded={open}
className="align-items-center d-flex flex-column flex-sm-row gap-2 gap-sm-3 fw-bold justify-content-center px-4 px-md-5 py-3 rounded-3 shadow-sm text-nowrap"
onClick={() => {
setOpen((v) => !v);
}}
size="lg"
style={{ maxWidth: "36rem", width: "100%" }}
variant="primary"
>
<Icon
aria-hidden
className="opacity-90"
type={open ? "chevron-up" : "chevron-down"}
/>
<span className="text-center" id={triggerLabelId}>
{scope.kind === "table"
? "Watch table progress over time"
: "Watch folder progress over time"}
</span>
</Button>
</div>
<Collapse in={open}>
<div className="mt-3" id={collapseId}>
{enumMetricChoices.length > 1 ? (
<div className="d-flex justify-content-center mb-3 px-2">
<div className="btn-group scrollbar-hide flex-wrap justify-content-center overflow-x-auto">
{enumMetricChoices.map((metric) => (
<SelectButton
className="text-wrap"
id={metric}
key={metric}
setValue={setEnumMetric}
value={enumMetric}
>
<Icon
type={
/* @ts-expect-error enum icon keys align with score metrics */
GPT_CLIENT_IMPLEMENTATIONS[game].enumIcons[metric]
}
/>{" "}
{UppercaseFirst(metric)}
</SelectButton>
))}
</div>
</div>
) : null}
{evoError ? <ApiError error={evoError} /> : null}
{open && evolutionLoading ? <Loading /> : null}
{evo && !evolutionLoading && !evoError ? (
<>
{events.length === 0 ? (
<small className="d-block mb-3 text-body-secondary">
{scope.kind === "table"
? "Nothing, yet for this table."
: "Nothing, yet for this folder."}
</small>
) : (
<>
<div className="row g-4">
<div className="col-12">
<EvolutionTimelineStrip
allEnumColours={allEnumColours}
axisSpan={axisSpan}
enumMetric={enumMetric}
gameConfig={gameConfig}
maxAxis={maxAxis}
minAxis={minAxis}
rangeId={rangeId}
rangeStep={rangeStep}
scrubTimeMs={scrubTimeMs}
setPlaying={setPlaying}
setScrubTimeMs={setScrubTimeMs}
workingEvents={workingEvents}
/>
<div
className="border-primary border-opacity-50 bg-primary bg-opacity-10 border min-w-0 mt-4 px-3 py-4 rounded-4 shadow-sm text-center w-100"
style={{ borderWidth: "2px" }}
>
<div className="fw-semibold lh-sm pb-2 small text-body-secondary text-uppercase">
Current replay time
</div>
<div
className="display-5 fw-bold text-body-emphasis tabular-nums text-truncate"
title={FormatDate(scrubTimeMs)}
>
{FormatDate(scrubTimeMs)}
</div>
<div
className="lh-sm mt-2 text-truncate"
title={MillisToSince(scrubTimeMs) ?? undefined}
>
<Muted>{MillisToSince(scrubTimeMs)}</Muted>
</div>
</div>
<div className="d-flex justify-content-center flex-wrap align-items-stretch gap-3 mt-4">
<Button
className="align-items-center d-inline-flex fw-bold gap-3 px-5 py-3 shadow-sm"
onClick={onPlayToggle}
size="lg"
type="button"
variant="success"
>
<Icon
aria-hidden
className="fs-3"
type={playing ? "pause" : "play"}
/>
<span>
{playing ? "Pause replay" : "Play replay"}
</span>
</Button>
<div className="d-flex align-items-center">
<Dropdown>
<Dropdown.Toggle
className="h-100"
id={`replay-playback-menu-${replayDomId}`}
variant="outline-secondary"
>
<span className="d-inline-flex align-items-center gap-2 fw-semibold">
<span>Playback</span>
<span className="small tabular-nums text-body-secondary">
{playbackSpeed}&times;
</span>
</span>
</Dropdown.Toggle>
<Dropdown.Menu>
{PLAYBACK_SPEED_OPTIONS.map((rate) => (
<Dropdown.Item
active={playbackSpeed === rate}
as="button"
key={rate}
onClick={() =>
setPlaybackSpeed(rate)
}
type="button"
>
{rate}&times;
{rate === 1 ? (
<span className="text-body-secondary small ms-1">
(normal)
</span>
) : null}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
</div>
</div>
</div>
</div>
<div className="row g-4 mt-4">
<div className="col-12 col-lg-7">
<h6 className="text-body-secondary mb-3">
{scope.kind === "table"
? "Folders at"
: "Folder at"}{" "}
<span className="fw-semibold tabular-nums text-body-emphasis">
{FormatDate(scrubTimeMs)}
</span>
</h6>
<div
className="border bg-body-secondary bg-opacity-10 gap-3 rounded vstack px-3 py-2"
ref={foldersReplayPanelRef}
>
{replayRows.map(({ folder, stats }) => (
<div
className={`${folderTableStyles.folderRow} bg-body-tertiary bg-opacity-25 rounded px-2 py-2`}
key={folder.slug}
>
<div className="d-flex flex-column flex-lg-row align-items-lg-center gap-2 gap-lg-3">
<div
className={`fw-semibold text-truncate ${folderTableStyles.folderRowTitle}`}
>
{folder.title}
</div>
<div
className="flex-grow-1 min-w-0"
style={{
minWidth: "10rem",
}}
>
<FolderEnumProgressBar
animateSegments={false}
clipToMinimumRelevance
colours={enumColours}
enumMetric={enumMetric}
gameConfig={gameConfig}
stats={stats}
/>
</div>
<span
className={`text-body-secondary ${folderTableStyles.folderRowChartMeta}`}
>
<span
className={
folderTableStyles.folderRowChartCount
}
>
{stats.chartCount}
</span>
</span>
</div>
{scope.kind === "folder" ? (
<FolderEnumDistributionBreakdown
clipToMinimumRelevance
colours={enumColours}
enumMetric={enumMetric}
gameConfig={gameConfig}
remainderLabel="Unfilled bar (no qualifying milestone)"
stats={stats}
/>
) : null}
</div>
))}
</div>
</div>
<div className="col-12 col-lg-5">
<div className="mb-3">
<h6 className="mb-1 text-body-secondary">Scores</h6>
</div>
<div
className="border bg-body-secondary bg-opacity-10 d-flex flex-column gap-3 overflow-auto px-3 py-3 rounded-3"
style={{
minHeight: "400px",
overflowX: "hidden",
...(milestoneFeedFrameHeightPx > 0
? {
height: milestoneFeedFrameHeightPx,
}
: {}),
}}
>
{visibleFeedRows.length === 0 ? (
<small className="d-block mb-1 text-body-secondary">
Nothing before this position.
</small>
) : (
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 (
<EvolutionMilestoneFeedRow
articleStyle={feedRowAccent(
evRow.metric,
evRow.value,
)}
chart={chart}
chipBg={chipBg}
chipFill={chipFill}
evRow={evRow}
game={game}
key={`${evRow.scoreID}-${evRow.metric}-${evRow.enumIndex}`}
song={song}
/>
);
})
)}
</div>
</div>
</div>
</>
)}
</>
) : null}
</div>
</Collapse>
</section>
);
}
@@ -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<string, UGPTFolderStats>;
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<string, Record<string, string>>
| 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 (
<div className="mt-4">
{dataset.length === 0 ? (
<div className="text-center text-body-secondary py-5">No folders.</div>
) : (
dataset.map((data) => {
const isFolderOpen = highlightFolderSlug === data.folder.slug;
const linkKey =
isFolderOpen && highlightRevealKey > 0
? `${data.folder.slug}--hilite--${highlightRevealKey}`
: data.folder.slug;
return (
<Link
className={`${folderTableStyles.folderRow} bg-body-tertiary bg-opacity-25${
isFolderOpen ? ` ${folderTableStyles.folderRowHighlighted}` : ""
}`}
key={linkKey}
onClick={() => {
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}`}
>
<div className="d-flex flex-column flex-lg-row align-items-lg-center gap-2 gap-lg-3">
<div
className={`fw-semibold text-truncate ${folderTableStyles.folderRowTitle}`}
>
{data.folder.title}
</div>
<div className="flex-grow-1 min-w-0" style={{ minWidth: "10rem" }}>
<FolderEnumProgressBar
colours={enumColours}
enumMetric={enumMetric}
gameConfig={gameConfig}
key={`${enumMetric}-${data.folder.slug}`}
stats={data.stats}
/>
</div>
<div
className={`d-flex align-items-center ms-lg-auto ${folderTableStyles.folderRowChartMeta}`}
>
<span
className={`text-body-secondary ${folderTableStyles.folderRowChartCount}`}
>
{data.stats.chartCount}
</span>
<Icon
aria-hidden
className="text-body-secondary opacity-75"
type="chevron-right"
/>
</div>
</div>
</Link>
);
})
)}
</div>
);
}
@@ -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<UGPTTableReturns>(
`/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<Map<string, UGPTFolderStats>>(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 <ApiError error={error} />;
}
if (!data || !hasLoadedFolderMap) {
return <Loading />;
}
return (
<>
<TableEvolutionReplay
controlledEnumMetric={[enumMetric, setEnumMetric]}
game={game}
gameConfig={gameConfig}
reqUser={reqUser}
scope={evolutionScope}
/>
<Divider className="my-0 border-2" />
<TableFolderList
dataMap={dataMap}
enumMetric={enumMetric}
game={game}
highlightFolderSlug={highlightFolderSlug}
highlightRevealKey={highlightRevealKey}
onFolderRowNavigate={onFolderRowNavigate}
reqUser={reqUser}
table={table}
/>
</>
);
}
@@ -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<string, string[]>;
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<string, Record<string, number>>();
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<string, integer> = {};
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 },
};
}
@@ -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<string>(`${preferredDefaultEnum}-stats`);
const enumGraphs = ["minimap", "stats"];
const folderStatsUrl = `/users/${reqUser.id}/games/${game}/folders/${folderSlug}/stats`;
const {
data: folderStatsBody,
error: folderStatsError,
isLoading: folderStatsLoading,
} = useApiQuery<UGPTFolderSlugStatsReturns>(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<string, Record<string, string>>
| undefined;
return (
<Card header={`${reqUser.username}'s ${folderTitle} Breakdown`}>
<div className="col-12 d-flex justify-content-center">
<div className="btn-group">
{enumGraphs.flatMap((g) =>
Object.keys(enumConf).flatMap((en) => (
<SelectButton
className={
g === "minimap" ? "d-none d-lg-block text-wrap" : "text-wrap"
}
id={`${en}-${g}`}
key={`${en}-${g}`}
setValue={setCurrentGraph}
value={currentGraph}
>
{/* @ts-expect-error zzz */}
<Icon type={gptImpl.enumIcons[en]} /> {UppercaseFirst(en)}{" "}
{UppercaseFirst(g)}
</SelectButton>
)),
)}
<SelectButton
className="text-wrap"
id="score"
setValue={setCurrentGraph}
value={currentGraph}
>
<Icon type="sort" /> Score Averages
</SelectButton>
</div>
<div className="vstack gap-3">
{folderStatsError ? (
<ApiError error={folderStatsError} />
) : folderStatsLoading ? (
<Loading />
) : (
(() => {
const liveFolderStats = folderStatsBody?.stats;
if (liveFolderStats === null || liveFolderStats === undefined) {
return (
<small className="text-body-secondary">
Could not load folder statistics.
</small>
);
}
return (
<div className="row g-3">
{enumMetrics.map((metric) => (
<div
className={
enumMetrics.length === 1 ? "col-12" : "col-12 col-md-6"
}
key={metric}
>
<FolderEnumDistributionBreakdown
clipToMinimumRelevance={false}
colours={enumColourMaps?.[metric]}
enumMetric={metric}
gameConfig={gameConfig}
onEnumBreakdownRowClick={
onBreakdownEnumValueClick
? (enumValueLabel: string) => {
onBreakdownEnumValueClick(metric, enumValueLabel);
}
: undefined
}
stats={liveFolderStats}
suppressTopRule
/>
</div>
))}
</div>
);
})()
)}
</div>
<div className="col-12">
<Divider />
</div>
{type === "stats" ? (
<FolderScoreDistributionChart
folderDataset={folderDataset}
game={game}
view={metric}
/>
) : type === "minimap" ? (
<FolderMinimap
enumMetric={metric}
folderDataset={folderDataset}
game={game}
reqUser={reqUser}
/>
) : (
<FolderScoreAverages folderDataset={folderDataset} game={game} reqUser={reqUser} />
)}
</Card>
);
}
@@ -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 (
<OverlayTrigger
delay={overlayDelay}
@@ -0,0 +1,70 @@
/* Shell around the Bootstrap input-group filter bar */
.filterBarChrome {
align-self: flex-end;
border-radius: var(--bs-border-radius);
flex: none;
max-width: 100%;
overflow: hidden;
transition:
box-shadow 0.22s ease,
background-color 0.22s ease,
filter 0.22s ease;
width: auto;
}
.filterBarChromeActive {
background-color: color-mix(in srgb, var(--bs-primary) 12%, transparent);
box-shadow:
inset 0 0 0 2px var(--bs-primary),
0 0 0 3px color-mix(in srgb, var(--bs-primary) 18%, transparent),
0 8px 24px color-mix(in srgb, var(--bs-primary) 12%, transparent);
:global(.form-control) {
background-color: color-mix(in srgb, var(--bs-primary) 6%, var(--bs-body-bg, #fff));
&:focus {
border-color: var(--bs-border-color);
box-shadow: none;
}
}
:global(.btn),
:global(.input-group-text) {
background-color: color-mix(in srgb, var(--bs-primary) 8%, var(--bs-body-bg, #fff));
border-left-color: var(--bs-border-color);
color: inherit;
}
}
/* Extra punch when syncing from preset (combined with `.filterBarChromeActive`) */
.searchFilterFlash {
animation: tachiFilterAttentionBurst 1.15s cubic-bezier(0.33, 1, 0.68, 1);
}
@keyframes tachiFilterAttentionBurst {
0% {
background-color: color-mix(in srgb, var(--bs-primary) 12%, transparent);
box-shadow:
inset 0 0 0 2px var(--bs-primary),
0 0 0 3px color-mix(in srgb, var(--bs-primary) 18%, transparent),
0 8px 24px color-mix(in srgb, var(--bs-primary) 12%, transparent);
}
28% {
background-color: color-mix(in srgb, var(--bs-primary) 24%, transparent);
box-shadow:
inset 0 0 0 3px color-mix(in srgb, var(--bs-warning) 35%, var(--bs-primary)),
0 0 0 7px color-mix(in srgb, var(--bs-primary) 38%, transparent),
0 14px 40px color-mix(in srgb, var(--bs-primary) 28%, transparent);
filter: saturate(1.12);
}
100% {
background-color: color-mix(in srgb, var(--bs-primary) 12%, transparent);
box-shadow:
inset 0 0 0 2px var(--bs-primary),
0 0 0 3px color-mix(in srgb, var(--bs-primary) 18%, transparent),
0 8px 24px color-mix(in srgb, var(--bs-primary) 12%, transparent);
filter: none;
}
}
@@ -6,12 +6,13 @@ import { UserSettingsContext } from "#context/UserSettingsContext";
import { WindowContext } from "#context/WindowContext";
import { CopyToClipboard } from "#util/misc";
import { ComposeSearchFunction, type SearchFunctions } from "#util/ztable/search";
import React, { type Key, useContext, useMemo, useState } from "react";
import React, { type Key, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "react-bootstrap";
import Form from "react-bootstrap/Form";
import InputGroup from "react-bootstrap/InputGroup";
import { type integer } from "tachi-common";
import tachitableStyles from "./TachiTable.module.scss";
import FilterDirectivesIndicator from "./FilterDirectivesIndicator";
import NoDataWrapper from "./NoDataWrapper";
import PageSelector from "./PageSelector";
@@ -98,6 +99,7 @@ export default function TachiTable<D>({
noBottomDisplayPager = false,
pageLenOptions = DEFAULT_TACHI_PAGE_LEN_OPTIONS,
rowKey,
externalSearchPreset = undefined,
}: {
dataset: D[];
defaultReverseSort?: boolean;
@@ -112,8 +114,15 @@ export default function TachiTable<D>({
/** Stable keys for tbody rows (e.g. chartID) so filtering reconciles instead of re-mounting. */
rowKey?: (row: D) => Key;
searchFunctions?: SearchFunctions<D>;
/**
* 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<number | undefined>(undefined);
const [highlightFilterPulse, setHighlightFilterPulse] = useState(false);
const searchFunction = useMemo(
() => (searchFunctions ? ComposeSearchFunction(searchFunctions) : undefined),
@@ -161,6 +170,55 @@ export default function TachiTable<D>({
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 (
<div>
<div className="hstack justify-content-between">
@@ -168,20 +226,25 @@ export default function TachiTable<D>({
<div className="d-none d-lg-flex align-self-center">{displayStr}</div>
)}
{searchFunctions && (
<InputGroup className="ms-lg-auto" style={{ maxWidth: isLg ? 384 : undefined }}>
<Form.Control
onChange={(e) => setSearch(e.target.value)}
placeholder={`Filter ${entryName}`}
type="text"
value={search}
/>
{dataset[0] && (
<FilterDirectivesIndicator
doc={dataset[0]}
searchFunctions={searchFunctions}
<div
className={`ms-lg-auto ${filterChromeClass}`}
style={{ maxWidth: isLg ? 384 : undefined }}
>
<InputGroup>
<Form.Control
onChange={(e) => setSearch(e.target.value)}
placeholder={`Filter ${entryName}`}
type="text"
value={search}
/>
)}
</InputGroup>
{dataset[0] && (
<FilterDirectivesIndicator
doc={dataset[0]}
searchFunctions={searchFunctions}
/>
)}
</InputGroup>
</div>
)}
</div>
<div className="px-0 mt-4 mb-4 overflow-x-auto overflow-x-lg-hidden">
@@ -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<FolderDataset[0]>[] = [
ChartHeader(game, (k) => k),
FolderIndicatorHeader,
@@ -58,7 +100,7 @@ export default function FolderTable({ dataset, game }: { dataset: FolderDataset;
);
return (
<>
<div id={FOLDER_FOLDER_TABLE_SCROLL_INTO_VIEW_ID}>
<Alert className="mb-4" variant="warning">
<div className="d-flex flex-column flex-md-row align-items-md-center gap-3">
<p className="flex-grow-1 mb-0">
@@ -79,6 +121,7 @@ export default function FolderTable({ dataset, game }: { dataset: FolderDataset;
<TachiTable
dataset={dataset}
entryName="Charts"
externalSearchPreset={externalPreset}
headers={headers}
key={usePaginatedView ? "folder-paginated" : "folder-full"}
pageLen={usePaginatedView ? 10 : 1000}
@@ -87,7 +130,7 @@ export default function FolderTable({ dataset, game }: { dataset: FolderDataset;
rowKey={(d) => d.chartID}
searchFunctions={folderSearchFunctions}
/>
</>
</div>
);
}
@@ -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<string, string[]>;
folders: FolderDocument[];
songs: SongDocument<GameGroup>[];
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<string, string[]>;
folders: FolderDocument[];
songs: SongDocument<GameGroup>[];
}
export type UGPTEvolutionReplayReturns = UGPTTableEvolutionReturns | UGPTFolderEvolutionReturns;
export interface UGPTFolderReturns<GPT extends V3Game = V3Game> {
folder: FolderDocument;
songs: SongDocument<GameGroup>[];
@@ -142,6 +174,12 @@ export interface UGPTFolderReturns<GPT extends V3Game = V3Game> {
pbs: PBScoreDocument<GPT>[];
}
/** `GET .../folders/:folderSlug/stats`. */
export interface UGPTFolderSlugStatsReturns {
folder: FolderDocument;
stats: FolderStatsInfo | null;
}
export interface GPTFolderReturns<GPT extends V3Game = V3Game> {
folder: FolderDocument;
songs: SongDocument<GameGroup>[];
+5
View File
@@ -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"],
@@ -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<FolderDocument>): Promise<{
distinctChartIDs: Array<string>;
folderChartIDs: Record<string, Array<string>>;
}> {
const folderChartIDs: Record<string, Array<string>> = {};
const distinct = new Set<string>();
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<string>,
): Promise<{ charts: Array<ChartDocument>; songs: Array<SongDocument> }> {
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<ScoreDocument>,
enumConfs: Record<string, ConfEnumScoreMetric<string>>,
): Array<TableEvolutionEvent> {
const evolutionEvents: Array<TableEvolutionEvent> = [];
let currentChartID: string | undefined;
const prevMaxForMetric: Record<string, integer> = {};
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<FolderDocument>,
table: TableDocument,
): Promise<{
charts: Array<ChartDocument>;
events: Array<TableEvolutionEvent>;
folderChartIDs: Record<string, Array<string>>;
folders: Array<FolderDocument>;
songs: Array<SongDocument>;
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<ChartDocument>;
events: Array<TableEvolutionEvent>;
folder: FolderDocument;
folderChartIDs: Record<string, Array<string>>;
folders: Array<FolderDocument>;
songs: Array<SongDocument>;
}> {
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,
};
}
@@ -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<ChartDocument>(),
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<FolderDocument>(),
songs: docArray<SongDocument>(),
table: doc<TableDocument>(),
}),
},
// ────────────────────────────────────────────────
// 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<ChartDocument>(),
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<FolderDocument>(),
folderChartIDs: z.record(z.string(), z.array(z.string())),
folders: docArray<FolderDocument>(),
songs: docArray<SongDocument>(),
}),
},
// ────────────────────────────────────────────────
// UGPT Targets
// ────────────────────────────────────────────────
@@ -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);
});
});
@@ -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,
);
},
);
@@ -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);
});
});
});
@@ -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,
);
},
);
@@ -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<Array<ScoreDocument>> {
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));
}