diff --git a/.cursor/skills/no-return-await/SKILL.md b/.cursor/skills/no-return-await/SKILL.md new file mode 100644 index 000000000..70d6bbdd1 --- /dev/null +++ b/.cursor/skills/no-return-await/SKILL.md @@ -0,0 +1,56 @@ +--- +name: no-return-await +description: Prefer `return` over `return await` in async functions when the await is redundant. Use when writing or reviewing async TypeScript/JavaScript, simplifying control flow, or fixing redundant awaits on returned promises. +--- + +# Prefer `return` over `return await` + +## Default rule + +In an `async function`, **`return await x` and `return x` are equivalent** for callers when `x` is a Promise (or thenable): both return a Promise with the same fulfillment and rejection. + +Prefer **`return x`** — the extra `await` adds a microtask and obscures that you are simply forwarding the result. + +```typescript +// Prefer +async function load() { + return fetchData(); +} + +// Avoid (unless you need await for control flow — see below) +async function load() { + return await fetchData(); +} +``` + +## When you must `await` + +Use **`await`** (including `return await`) only when you need the async function to **suspend on that operation** for control flow: + +- **`try` / `catch` / `finally`**: To handle or finalize on **rejection** of the inner promise, you must `await` it inside `try`. A bare `return innerPromise()` does **not** route that rejection through `catch` / `finally` the same way. + +```typescript +async function safeLoad() { + try { + // `return await` is correct here so `catch` sees rejections from fetchData() + return await fetchData(); + } catch (e) { + return defaultData(); + } +} +``` + +- **`finally`** that must run after the inner work settles (same idea — often needs `await` in the `try`). + +If there is **no** `try`/`catch`/`finally` depending on that promise settling inside the function, **do not** use `return await`. + +## Review checklist + +- [ ] If the last statement is `return await expr` and nothing in the function uses `try`/`catch`/`finally` around that path for that promise → use `return expr`. +- [ ] If `catch` or `finally` must apply to failures of `expr` → keep `await` (often `return await expr` in the `try`). + +## Why + +- Clearer intent: forwarding a Promise vs. explicitly sequencing. +- Slightly leaner: no unnecessary `await` + re-wrapping. +- Aligns with common ESLint `no-return-await` guidance (with the try/catch exception above). diff --git a/.cursor/skills/preserve-comments/SKILL.md b/.cursor/skills/preserve-comments/SKILL.md new file mode 100644 index 000000000..908b11a7f --- /dev/null +++ b/.cursor/skills/preserve-comments/SKILL.md @@ -0,0 +1,25 @@ +--- +name: preserve-comments +description: Preserves existing code comments (line, block, JSDoc, region markers) when editing files; does not delete or collapse comments for brevity unless the user asked to remove or update them. Use when editing any source file, refactoring, or applying fixes where comments already exist. +--- + +# Preserve comments when editing + +## Default rule + +When changing code, **keep existing comments** in place unless one of the exceptions below applies. + +- **Line comments** (`//`), **block comments** (`/* */`), **JSDoc/TSDoc** (`/** */`), and **region/pragma-style markers** the file already uses — preserve them; do not strip them to “clean up” or shorten the diff. +- If a comment sits next to changed logic, **update the comment only when the behavior it describes changed**. Otherwise leave it untouched. +- **Do not** replace a nuanced comment with nothing, or with a shorter generic note, unless the user asked for that. + +## When removal or heavy edits are OK + +- The user **explicitly** asked to remove comments, delete dead documentation, or “dedupe” comments. +- The comment is **factually wrong** after the edit and would mislead readers — then fix or remove that comment and prefer a short accurate replacement over silence. +- The **entire block of code** the comment referred to is deleted — remove or relocate the comment so it does not orphan misleading text (or move it if it still applies elsewhere). + +## What this is not + +- This does not require adding new comments for every change. +- This does not forbid editing comments when they must track the code. diff --git a/.gitignore b/.gitignore index 0ac38b581..223a6e834 100644 --- a/.gitignore +++ b/.gitignore @@ -23,8 +23,9 @@ build # Coverage coverage test-results +.nyc_output -# Vitest bench --outputJson (see Justfile-bench bench-json) +# Vitest bench --outputJson (see Justfile-bench) typescript/server/bench-results.json *.tsbuildinfo diff --git a/Justfile-db b/Justfile-db index 42d316327..94d7e5483 100644 --- a/Justfile-db +++ b/Justfile-db @@ -5,15 +5,20 @@ DEFAULT_DB := "tachi_dev" db POSTGRES_DB=DEFAULT_DB: psql "{{POSTGRES_URL}}/{{POSTGRES_DB}}" -# Drop and recreate the database so migrations can be re-applied from scratch. -db-reset POSTGRES_DB=DEFAULT_DB: +db-cheap-reset POSTGRES_DB=DEFAULT_DB: #!/bin/bash - export POSTGRES_URL="{{POSTGRES_URL}}/{{POSTGRES_DB}}" tachidb database drop tachidb database create tachidb migrate run +# Drop and recreate the database so migrations can be re-applied from scratch. +db-reset POSTGRES_DB=DEFAULT_DB: + #!/bin/bash + + just db-cheap-reset "{{POSTGRES_DB}}" + just db-load-seeds "{{POSTGRES_DB}}" + # Show the status of all applied (and failed) migrations. db-migrate-status POSTGRES_DB=DEFAULT_DB: psql "{{POSTGRES_URL}}/{{POSTGRES_DB}}" -c \ @@ -27,5 +32,5 @@ db-load-seeds POSTGRES_DB=DEFAULT_DB: bun run src/scripts/load-seeds-pg.ts # Reset templatedb from migrations, then dump schema and indexes for local debugging. -db-schema: (db-reset "templatedb") +db-schema: (db-cheap-reset "templatedb") pg_dump "{{POSTGRES_URL}}/templatedb" --schema-only --no-owner --no-acl -f db/schema.sql \ No newline at end of file diff --git a/Justfile-gen b/Justfile-gen index c6356432b..eb83e3479 100644 --- a/Justfile-gen +++ b/Justfile-gen @@ -5,7 +5,7 @@ gen: gen-kanel gen-proto # Generate kanel types from the postgres definitions in Tachi. -gen-kanel: (db-reset "templatedb") +gen-kanel: (db-cheap-reset "templatedb") bun run kanel -d "{{ POSTGRES_URL }}/templatedb" -c db/kanel_config.js just fmt diff --git a/Justfile-migrate b/Justfile-migrate index 220ea7e8b..9177c7588 100644 --- a/Justfile-migrate +++ b/Justfile-migrate @@ -9,7 +9,7 @@ migrate-v3: # Migrate all MongoDB data into Postgres (run once, requires both DBs to be reachable). mongo-to-pg POSTGRES_DB_NAME MONGO_DB_NAME: - just db-reset "{{ POSTGRES_DB_NAME }}" + just db-cheap-reset "{{ POSTGRES_DB_NAME }}" cd typescript/server && VERSION=$(jq -r .version package.json) COMMIT_HASH=$(git rev-parse --short HEAD) POSTGRES_URL="{{POSTGRES_URL}}/{{POSTGRES_DB_NAME}}" MONGO_URL="mongodb://tachi-mongo/{{MONGO_DB_NAME}}" \ bun run src/scripts/migrate-to-postgres.ts diff --git a/Justfile-test b/Justfile-test index 5a6fc127e..84b6359d4 100644 --- a/Justfile-test +++ b/Justfile-test @@ -21,7 +21,7 @@ fix: set -euo pipefail just gen - bun run --sequential --elide-lines=0 --filter '*' lint-fix + bun run --elide-lines=0 --filter '*' lint-fix .scripts/ts_autoinherit.js just fmt diff --git a/bun.lock b/bun.lock index 564668a56..d62cc636b 100644 --- a/bun.lock +++ b/bun.lock @@ -426,6 +426,7 @@ "devDependencies": { "@typescript/native-preview": "catalog:", "eslint-config-tachi": "workspace:*", + "vitest": "catalog:", }, }, }, diff --git a/db/migrations/20260301154256_genesis.sql b/db/migrations/20260301154256_genesis.sql index 78c319978..0bd9f1a1f 100644 --- a/db/migrations/20260301154256_genesis.sql +++ b/db/migrations/20260301154256_genesis.sql @@ -625,7 +625,13 @@ CREATE TABLE "session" ( time_ended TIMESTAMPTZ NOT NULL, calculated_data JSONB NOT NULL, - highlight BOOLEAN NOT NULL + highlight BOOLEAN NOT NULL, + + -- Session UGPT search: FTS + trgm (see lib/search/session-search.ts). + textsearch tsvector NOT NULL GENERATED ALWAYS AS ( + setweight(to_tsvector('simple', coalesce(name, '')), 'A') || + setweight(to_tsvector('simple', coalesce(description, '')), 'B') + ) STORED ); CREATE TABLE "import" ( @@ -646,7 +652,7 @@ CREATE TABLE "import" ( -- but importantly they're always in the same game group -- this is a random tachi2 holdover. CREATE TABLE "import_game" ( - id TEXT REFERENCES import(id) NOT NULL, + id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL, game GAME NOT NULL, PRIMARY KEY (id, game) @@ -655,7 +661,7 @@ CREATE TABLE "import_game" ( CREATE TABLE "import_error" ( row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(), - import_id TEXT REFERENCES import(id) NOT NULL, + import_id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL, type TEXT NOT NULL, message TEXT NOT NULL ); @@ -663,7 +669,7 @@ CREATE TABLE "import_error" ( CREATE TABLE "import_session" ( row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(), - import_id TEXT REFERENCES import(id) NOT NULL, + import_id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL, session_id TEXT REFERENCES session(id) NOT NULL, type TEXT NOT NULL CHECK (type IN ('appended', 'created')), @@ -673,7 +679,7 @@ CREATE TABLE "import_session" ( CREATE TABLE "import_class" ( row_id UUID PRIMARY KEY DEFAULT uuidv7(), - import_id TEXT REFERENCES import(id) NOT NULL, + import_id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL, game GAME NOT NULL, set TEXT NOT NULL, prev TEXT, @@ -682,7 +688,7 @@ CREATE TABLE "import_class" ( CREATE TABLE "import_timing" ( - id TEXT PRIMARY KEY REFERENCES import(id) NOT NULL, + id TEXT PRIMARY KEY REFERENCES import(id) ON DELETE CASCADE NOT NULL, timestamp TIMESTAMPTZ NOT NULL, import_secs_avg FLOAT8 NOT NULL, @@ -738,7 +744,10 @@ CREATE TABLE "score" ( time_added TIMESTAMPTZ NOT NULL, highlight BOOLEAN NOT NULL, - comment TEXT + comment TEXT, + + -- Staging: false until post-import steps finish; then true (see score_import_uncommitted_idx). + committed BOOLEAN NOT NULL DEFAULT TRUE ); CREATE TABLE "pb" ( @@ -758,6 +767,7 @@ CREATE TABLE "pb" ( data JSONB NOT NULL, derived_data JSONB NOT NULL, calculated_data JSONB NOT NULL, + judgements JSONB NOT NULL, -- how to rank scores. Five (sorry, hardcoded) additional -- tiebreakers are available. @@ -783,11 +793,13 @@ CREATE TABLE "pb_composed_from" ( PRIMARY KEY (pb_id, score_id) ); +-- Global chart rank/outOf live in one place: the leaderboard window over `pb`, +-- not duplicated into `calculated_data`. CREATE VIEW "chart_leaderboard" AS SELECT pb.*, RANK() OVER ( - PARTITION BY chart_id + PARTITION BY chart_id, lens ORDER BY ranking_value DESC NULLS LAST, ranking_value_tb1 DESC NULLS LAST, @@ -795,15 +807,20 @@ SELECT ranking_value_tb3 DESC NULLS LAST, ranking_value_tb4 DESC NULLS LAST, ranking_value_tb5 DESC NULLS LAST, - -- oldest time_achieved wins ties time_achieved ASC NULLS LAST - ) AS rank + ) AS rank, + COUNT(*) OVER (PARTITION BY chart_id, lens) AS out_of FROM pb; + CREATE TABLE "orphan_score" ( row_id UUID PRIMARY KEY NOT NULL DEFAULT uuidv7(), user_id BIGINT REFERENCES account(id) NOT NULL, - import_id TEXT REFERENCES import(id) NOT NULL, + import_id TEXT REFERENCES import(id) ON DELETE SET NULL, + + orphan_id TEXT NOT NULL, + import_type IMPORT_TYPE NOT NULL, + game_group GAME_GROUP NOT NULL, data JSONB NOT NULL, context JSONB NOT NULL, @@ -944,7 +961,7 @@ CREATE TABLE "questline_quest" ( CREATE TABLE "import_quest" ( row_id UUID PRIMARY KEY DEFAULT uuidv7(), - import_id TEXT REFERENCES import(id) NOT NULL, + import_id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL, quest_id TEXT REFERENCES quest(id) NOT NULL, prev_achieved BOOLEAN NOT NULL, @@ -957,7 +974,7 @@ CREATE TABLE "import_quest" ( CREATE TABLE "import_goal" ( row_id UUID PRIMARY KEY DEFAULT uuidv7(), - import_id TEXT REFERENCES import(id) NOT NULL, + import_id TEXT REFERENCES import(id) ON DELETE CASCADE NOT NULL, goal_id TEXT REFERENCES goal(id) NOT NULL, prev_achieved BOOLEAN NOT NULL, @@ -992,6 +1009,13 @@ CREATE INDEX score_user_highlights_idx ON score (user_id, time_added DESC) -- Needed separately because score_user_chart_idx has user_id as the left column. CREATE INDEX score_chart_idx ON score (chart_id); +-- Failed-import cleanup: delete uncommitted rows by import_id. +CREATE INDEX score_import_uncommitted_idx ON score (import_id) + WHERE + committed = FALSE; + +CREATE UNIQUE INDEX orphan_score_orphan_id_key ON orphan_score (orphan_id); + -- pb (5M rows) — hottest table -- The single most important index in the schema. -- Serves: chart_leaderboard view, leaderboard pagination, and the COUNT rank @@ -1021,6 +1045,12 @@ CREATE INDEX session_user_game_idx ON session (user_id, game, time_started DESC) -- Global game activity feed ("recent sessions for IIDX globally"). CREATE INDEX session_game_recent_idx ON session (game, time_started DESC); +CREATE INDEX session_textsearch_gin ON session USING GIN (textsearch); + +CREATE INDEX session_name_trgm ON session USING GIN (name gin_trgm_ops); + +CREATE INDEX session_description_trgm ON session USING GIN (description gin_trgm_ops); + -- import -- User's import history page. time_started DESC for newest-first ordering. CREATE INDEX import_user_idx ON import (user_id, time_started DESC); diff --git a/db/seeds/songs-sdvx.json b/db/seeds/songs-sdvx.json index da6c82861..5b39c23d1 100644 --- a/db/seeds/songs-sdvx.json +++ b/db/seeds/songs-sdvx.json @@ -23243,7 +23243,7 @@ "title": "Timepiece phase Ⅱ" }, { - "altTitles": ["¡¡Fanta∽cramble!!"], + "altTitles": [], "artist": "Potwi", "data": { "displayVersion": "exceed" diff --git a/typescript/bot/src/actions.ts b/typescript/bot/src/actions.ts index 26db66654..96a9498af 100644 --- a/typescript/bot/src/actions.ts +++ b/typescript/bot/src/actions.ts @@ -56,6 +56,8 @@ export function MakeAnonAction( db, appName: APP_NAME, kind, + inputSchema: AnonActionSignatures[kind].input, + outputSchema: AnonActionSignatures[kind].output, // @ts-expect-error we're being creative with the types here fn, }) as AnonActionFn; @@ -70,6 +72,8 @@ export function MakeAction(kind: A, fn: ActionFn): Acti db, appName: APP_NAME, kind, + inputSchema: ActionSignatures[kind].input, + outputSchema: ActionSignatures[kind].output, // @ts-expect-error we're being creative with the types here fn, }) as ActionFn; diff --git a/typescript/bot/src/actions/sync.test.ts b/typescript/bot/src/actions/sync.test.ts index 49f2010fd..642ab2cbb 100644 --- a/typescript/bot/src/actions/sync.test.ts +++ b/typescript/bot/src/actions/sync.test.ts @@ -111,7 +111,7 @@ describe("ACTION_Sync", () => { // node-pg returns BIGINT columns as strings expect(action).toMatchObject({ - app: "BOT", + app: "TACHI_BOT", kind: "SYNC", result: "GOOD", user_id: taker.acct.id, diff --git a/typescript/bot/src/anon-actions/letmein.test.ts b/typescript/bot/src/anon-actions/letmein.test.ts index 04288bf50..517a86447 100644 --- a/typescript/bot/src/anon-actions/letmein.test.ts +++ b/typescript/bot/src/anon-actions/letmein.test.ts @@ -63,7 +63,7 @@ describe("ACTION_Letmein", () => { // node-pg returns BIGINT columns as strings expect(action).toMatchObject({ - app: "BOT", + app: "TACHI_BOT", kind: "LETMEIN", result: "GOOD", user_id: null, diff --git a/typescript/bot/src/anon-actions/register.test.ts b/typescript/bot/src/anon-actions/register.test.ts index 03b6460d2..fe8e4f22b 100644 --- a/typescript/bot/src/anon-actions/register.test.ts +++ b/typescript/bot/src/anon-actions/register.test.ts @@ -31,9 +31,8 @@ describe("ACTION_Register", () => { .where("user_id", "=", userId) .executeTakeFirst(); - // node-pg returns BIGINT columns as strings expect(row).toMatchObject({ - user_id: String(userId), + user_id: userId, discord_id: discordId, api_token: apiToken, }); @@ -80,7 +79,7 @@ describe("ACTION_Register", () => { .executeTakeFirst(); expect(action).toMatchObject({ - app: "BOT", + app: "TACHI_BOT", kind: "REGISTER", result: "GOOD", user_id: null, diff --git a/typescript/bot/src/services/pg/db.ts b/typescript/bot/src/services/pg/db.ts index e251d664b..c73df68ae 100644 --- a/typescript/bot/src/services/pg/db.ts +++ b/typescript/bot/src/services/pg/db.ts @@ -2,7 +2,15 @@ import type { Database } from "tachi-db"; import { Env } from "#config"; import { Kysely, PostgresDialect } from "kysely"; -import { Pool } from "pg"; +import pg, { Pool } from "pg"; + +// pg returns BIGINT/BIGSERIAL (OID 20) as strings by default to avoid +// precision loss for very large values. Our IDs are well within Number.MAX_SAFE_INTEGER, +// so parse them as numbers to keep types consistent across the codebase. +pg.types.setTypeParser(pg.types.builtins.TIMESTAMPTZ, (val) => val); +pg.types.setTypeParser(pg.types.builtins.INT4, (val) => Number(val)); +pg.types.setTypeParser(pg.types.builtins.INT2, (val) => Number(val)); +pg.types.setTypeParser(pg.types.builtins.INT8, (val) => Number(val)); const pool = new Pool({ connectionString: Env.POSTGRES_URL }); diff --git a/typescript/client/src/app/App.tsx b/typescript/client/src/app/App.tsx index f8df250cb..75d37c6d9 100644 --- a/typescript/client/src/app/App.tsx +++ b/typescript/client/src/app/App.tsx @@ -1,3 +1,5 @@ +import { CustomScrollbar } from "#components/layout/CustomScrollbar"; +import { LocalDevMissingSeedsBanner } from "#components/layout/LocalDevMissingSeedsBanner"; import { LoadingScreen } from "#components/layout/screens/LoadingScreen"; import { AllLUGPTStatsContextProvider } from "#context/AllLUGPTStatsContext"; import { BannedContextProvider } from "#context/BannedContext"; @@ -21,6 +23,7 @@ export default function App({ basename }: { basename: string }) { return ( + @@ -30,6 +33,7 @@ export default function App({ basename }: { basename: string }) { + diff --git a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx index c4135798a..0e859e99c 100644 --- a/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx +++ b/typescript/client/src/app/pages/dashboard/users/games/_game/_playtype/UGPTSettingsPage.tsx @@ -100,9 +100,33 @@ export default function UGPTSettingsPage({ reqUser, game, playtype }: UGPT) {
{page === "preferences" ? ( - + loggedInData.settings ? ( + + ) : ( +
+ + No game settings exist yet for this profile. Submit a score or + import first, then return here. + +
+ ) ) : page === "showcase" ? ( - + loggedInData.settings ? ( + + ) : ( +
+ + No game settings exist yet for this profile. Submit a score or + import first, then return here. + +
+ ) ) : ( )} @@ -117,7 +141,7 @@ function PreferencesForm({ game, playtype, loggedInData, -}: { loggedInData: UGPTData } & UGPT) { +}: { loggedInData: { settings: MONGO_UGPTSettingsDocument } & UGPTData } & UGPT) { const { setLoggedInData } = useContext(UGPTContext); const settings = loggedInData.settings; @@ -127,17 +151,17 @@ function PreferencesForm({ const formik = useFormik({ initialValues: { preferredScoreAlg: - settings!.preferences.preferredScoreAlg || gptConfig.defaultScoreRatingAlg, + settings.preferences.preferredScoreAlg || gptConfig.defaultScoreRatingAlg, preferredProfileAlg: - settings!.preferences.preferredProfileAlg || gptConfig.defaultProfileRatingAlg, + settings.preferences.preferredProfileAlg || gptConfig.defaultProfileRatingAlg, preferredSessionAlg: - settings!.preferences.preferredSessionAlg || gptConfig.defaultSessionRatingAlg, + settings.preferences.preferredSessionAlg || gptConfig.defaultSessionRatingAlg, // eslint-disable-next-line @typescript-eslint/no-explicit-any - gameSpecific: settings!.preferences.gameSpecific as any, - defaultTable: settings!.preferences.defaultTable, + gameSpecific: settings.preferences.gameSpecific as any, + defaultTable: settings.preferences.defaultTable, preferredDefaultEnum: - settings!.preferences.preferredDefaultEnum ?? gptConfig.preferredDefaultEnum, - preferredRanking: settings!.preferences.preferredRanking ?? "global", + settings.preferences.preferredDefaultEnum ?? gptConfig.preferredDefaultEnum, + preferredRanking: settings.preferences.preferredRanking ?? "global", }, onSubmit: async (values) => { const rj = await APIFetchV1( @@ -177,7 +201,7 @@ function PreferencesForm({ } const displayableTables = tables.filter( - (e) => !e.inactive || settings?.preferences.defaultTable === e.tableID, + (e) => !e.inactive || settings.preferences.defaultTable === e.tableID, ); const formGroupClassNames = "d-flex flex-column"; @@ -407,12 +431,12 @@ function ShowcaseForm({ game, playtype, loggedInData, -}: { loggedInData: UGPTData } & UGPT) { +}: { loggedInData: { settings: MONGO_UGPTSettingsDocument } & UGPTData } & UGPT) { const { setLoggedInData } = useContext(UGPTContext); const settings = loggedInData.settings; - const [stats, setStats] = useState(settings!.preferences.stats); + const [stats, setStats] = useState(settings.preferences.stats); const [show, setShow] = useState(false); const SaveChanges = async () => { diff --git a/typescript/client/src/components/game/songs/IIDXStyleSongChartInfoFormat.tsx b/typescript/client/src/components/game/songs/IIDXStyleSongChartInfoFormat.tsx index f636107a9..56e838754 100644 --- a/typescript/client/src/components/game/songs/IIDXStyleSongChartInfoFormat.tsx +++ b/typescript/client/src/components/game/songs/IIDXStyleSongChartInfoFormat.tsx @@ -28,7 +28,7 @@ export default function IIDXStyleSongChartInfoFormat({ function LevelText(chart: MONGO_ChartDocument, game: GameGroup) { if ("tableFolders" in chart.data) { - const hasLevel = chart.data.tableFolders.length > 0; + const hasLevel = Object.keys(chart.data.tableFolders).length > 0; return hasLevel ? FormatTables(chart.data.tableFolders) : "No Level"; } return FormatDifficulty(chart, game); diff --git a/typescript/client/src/components/gpt-utils/tools/BMSSieglindeInfoTool.tsx b/typescript/client/src/components/gpt-utils/tools/BMSSieglindeInfoTool.tsx index 8adcf3fcb..e3a3bb227 100644 --- a/typescript/client/src/components/gpt-utils/tools/BMSSieglindeInfoTool.tsx +++ b/typescript/client/src/components/gpt-utils/tools/BMSSieglindeInfoTool.tsx @@ -100,7 +100,7 @@ function Component({ game, playtype }: UGPT) { BMS_TABLES.map((e) => [ e.asciiPrefix, (d: DatasetElement) => - !!d.data.tableFolders.find((k) => k.table === e.prefix), + !!Object.keys(d.data.tableFolders).find((k) => k === e.prefix), ]), )} /> diff --git a/typescript/client/src/components/layout/CustomScrollbar.module.scss b/typescript/client/src/components/layout/CustomScrollbar.module.scss new file mode 100644 index 000000000..e5a78e307 --- /dev/null +++ b/typescript/client/src/components/layout/CustomScrollbar.module.scss @@ -0,0 +1,37 @@ +.track { + position: fixed; + top: 0; + right: 0; + // Above .fixed-top (1030), below modal/offcanvas layers (~1050+) + z-index: 1035; + box-sizing: border-box; + width: var(--custom-scrollbar-track-width, 12px); + height: 100vh; + height: 100dvh; + padding: 2px; + pointer-events: auto; + touch-action: pan-y; +} + +.trackInner { + position: relative; + width: 100%; + height: 100%; + border-radius: 999px; + background-color: rgba(var(--bs-tertiary-bg-rgb), 0.35); +} + +.thumb { + position: absolute; + right: 0; + left: 0; + cursor: grab; + touch-action: none; + border-radius: 999px; + background-color: rgba(var(--bs-secondary-rgb), 0.45); + transition: background-color 0.12s ease; + + &:hover { + background-color: rgba(var(--bs-secondary-rgb), 0.65); + } +} diff --git a/typescript/client/src/components/layout/CustomScrollbar.tsx b/typescript/client/src/components/layout/CustomScrollbar.tsx new file mode 100644 index 000000000..057239eed --- /dev/null +++ b/typescript/client/src/components/layout/CustomScrollbar.tsx @@ -0,0 +1,134 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; + +import classes from "./CustomScrollbar.module.scss"; + +const THUMB_MIN_PX = 32; + +function readMetrics() { + const root = document.documentElement; + return { + scrollTop: root.scrollTop, + scrollHeight: root.scrollHeight, + clientHeight: root.clientHeight, + }; +} + +/** + * Hides the native document scrollbar via global CSS; renders a fixed overlay + * track so layout width stays stable while scrolling. + */ +export function CustomScrollbar() { + const [metrics, setMetrics] = useState(readMetrics); + const thumbRef = useRef(null); + + const update = useCallback(() => { + setMetrics(readMetrics()); + }, []); + + useEffect(() => { + update(); + window.addEventListener("scroll", update, { passive: true }); + window.addEventListener("resize", update); + const ro = new ResizeObserver(update); + ro.observe(document.documentElement); + ro.observe(document.body); + return () => { + window.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + ro.disconnect(); + }; + }, [update]); + + const { scrollTop, scrollHeight, clientHeight } = metrics; + const scrollable = Math.max(0, scrollHeight - clientHeight); + const show = scrollable > 1; + + const thumbH = + scrollable <= 0 ? 0 : Math.max(THUMB_MIN_PX, (clientHeight / scrollHeight) * clientHeight); + const thumbTravel = Math.max(0, clientHeight - thumbH); + const thumbTop = scrollable <= 0 ? 0 : (scrollTop / scrollable) * thumbTravel; + + const onThumbPointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) { + return; + } + e.preventDefault(); + const startY = e.clientY; + const startScrollTop = document.documentElement.scrollTop; + + const onMove = (ev: PointerEvent) => { + const cur = readMetrics(); + const sc = Math.max(0, cur.scrollHeight - cur.clientHeight); + if (sc <= 0) { + return; + } + const tH = Math.max( + THUMB_MIN_PX, + (cur.clientHeight / cur.scrollHeight) * cur.clientHeight, + ); + const tTrack = Math.max(0, cur.clientHeight - tH); + if (tTrack <= 0) { + return; + } + const deltaY = ev.clientY - startY; + const next = startScrollTop + (deltaY / tTrack) * sc; + document.documentElement.scrollTop = Math.max(0, Math.min(sc, next)); + }; + + const onUp = (ev: PointerEvent) => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + document.body.style.removeProperty("user-select"); + if (thumbRef.current?.hasPointerCapture(ev.pointerId)) { + thumbRef.current.releasePointerCapture(ev.pointerId); + } + }; + + thumbRef.current?.setPointerCapture(e.pointerId); + document.body.style.userSelect = "none"; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + }; + + const onTrackPointerDown = (e: React.PointerEvent) => { + if (e.button !== 0 || thumbRef.current?.contains(e.target as Node)) { + return; + } + const thumbEl = thumbRef.current; + if (!thumbEl) { + return; + } + const thumbRect = thumbEl.getBoundingClientRect(); + const mid = thumbRect.top + thumbRect.height / 2; + const page = clientHeight * 0.85; + if (e.clientY < mid) { + document.documentElement.scrollBy({ top: -page, behavior: "smooth" }); + } else { + document.documentElement.scrollBy({ top: page, behavior: "smooth" }); + } + }; + + if (!show) { + return null; + } + + return ( +
+
+
+
+
+ ); +} diff --git a/typescript/client/src/components/layout/LocalDevMissingSeedsBanner.tsx b/typescript/client/src/components/layout/LocalDevMissingSeedsBanner.tsx new file mode 100644 index 000000000..9e7f9fe01 --- /dev/null +++ b/typescript/client/src/components/layout/LocalDevMissingSeedsBanner.tsx @@ -0,0 +1,39 @@ +import { APIFetchV1 } from "#util/api"; +import React, { useEffect, useState } from "react"; +import Alert from "react-bootstrap/Alert"; + +/** + * Shown only when the Vite local-dev client talks to a dev server whose Postgres `song` + * table is empty (seeds were never loaded). + */ +export function LocalDevMissingSeedsBanner() { + const [show, setShow] = useState(false); + + useEffect(() => { + if (!import.meta.env.VITE_IS_LOCAL_DEV) { + return; + } + + void APIFetchV1<{ missingSongSeeds: boolean }>("/localdev/song-seed-status").then((r) => { + if (r.success && r.body.missingSongSeeds) { + setShow(true); + } + }); + }, []); + + if (!show) { + return null; + } + + return ( + + There are no seeds in the database.
+ Have you forgotten to run just db-load-seeds? +
+ ); +} diff --git a/typescript/client/src/components/tables/cells/BMSOrPMSDifficultyCell.tsx b/typescript/client/src/components/tables/cells/BMSOrPMSDifficultyCell.tsx index b5740abbb..0c3f9e9ea 100644 --- a/typescript/client/src/components/tables/cells/BMSOrPMSDifficultyCell.tsx +++ b/typescript/client/src/components/tables/cells/BMSOrPMSDifficultyCell.tsx @@ -25,7 +25,7 @@ export default function BMSOrPMSDifficultyCell({ settings: MONGO_UGPTSettingsDocument<"bms:7K" | "bms:14K">; }; - const hasLevel = chart.data.tableFolders.length > 0; + const hasLevel = Object.keys(chart.data.tableFolders).length > 0; const aiLevel = game === "bms" && (chart as MONGO_ChartDocument<"bms:7K" | "bms:14K">).data.aiLevel; @@ -33,18 +33,35 @@ export default function BMSOrPMSDifficultyCell({ let levelText = "No Rating"; let backgroundColour = hasLevel ? COLOUR_SET.red : COLOUR_SET.gray; - let filteredTables: Array<{ level: string; table: string }> = []; + let filteredTables: Record = {}; if (hasLevel) { let tables = chart.data.tableFolders; if (settings?.preferences.gameSpecific.displayTables) { - filteredTables = tables.filter( - (e) => !settings.preferences.gameSpecific.displayTables!.includes(e.table), - ); - tables = tables.filter((e) => - settings.preferences.gameSpecific.displayTables!.includes(e.table), - ); + filteredTables = Object.entries(tables) + .filter( + ([table, _]) => + !settings.preferences.gameSpecific.displayTables!.includes(table), + ) + .reduce( + (acc, [table, level]) => { + acc[table] = level; + return acc; + }, + {} as Record, + ); + tables = Object.entries(tables) + .filter(([table, _]) => + settings.preferences.gameSpecific.displayTables!.includes(table), + ) + .reduce( + (acc, [table, level]) => { + acc[table] = level; + return acc; + }, + {} as Record, + ); } levelText = FormatTables(tables); @@ -71,7 +88,7 @@ export default function BMSOrPMSDifficultyCell({
- ) : filteredTables.length > 0 ? ( + ) : Object.keys(filteredTables).length > 0 ? ( @@ -100,8 +117,8 @@ function FindTableColour( ) { const lookup = new Map(BMS_TABLES.map((e) => [e.prefix, e.colour])); - for (const table of tableFolders) { - const colour = lookup.get(table.table); + for (const table of Object.keys(tableFolders)) { + const colour = lookup.get(table); if (colour) { return colour; diff --git a/typescript/client/src/components/tables/scores/ScoreTable.tsx b/typescript/client/src/components/tables/scores/ScoreTable.tsx index 528158149..d72fc32b1 100644 --- a/typescript/client/src/components/tables/scores/ScoreTable.tsx +++ b/typescript/client/src/components/tables/scores/ScoreTable.tsx @@ -1,3 +1,4 @@ +import DebugContent from "#components/util/DebugContent.js"; import useScoreRatingAlg from "#components/util/useScoreRatingAlg"; import { type ScoreDataset } from "#types/tables"; import { NumericSOV, StrSOV } from "#util/sorts"; diff --git a/typescript/client/src/components/util/query/fetchUGPTData.ts b/typescript/client/src/components/util/query/fetchUGPTData.ts index a24a7dcd2..6142b5f8e 100644 --- a/typescript/client/src/components/util/query/fetchUGPTData.ts +++ b/typescript/client/src/components/util/query/fetchUGPTData.ts @@ -15,7 +15,7 @@ import { * It's worth keeping this around for both the currently-viewed user and the currently-logged-in user. */ export interface UGPTData { - settings: MONGO_UGPTSettingsDocument; + settings: MONGO_UGPTSettingsDocument | null; stats: MONGO_UserGameStats; game: GameGroup; playtype: Playtype; @@ -48,7 +48,7 @@ export default async function fetchUGPTData( ); } - const settingsRes = await APIFetchV1( + const settingsRes = await APIFetchV1( `/users/${userID}/games/${game}/${playtype}/settings`, ); diff --git a/typescript/client/src/lib/config.ts b/typescript/client/src/lib/config.ts index dc758c789..2dbcb541b 100644 --- a/typescript/client/src/lib/config.ts +++ b/typescript/client/src/lib/config.ts @@ -1,4 +1,4 @@ -import { ToAPIURL } from "#util/api"; +import { ToAbsoluteAPIURLForHelpLink, ToAPIURL } from "#util/api"; import { type TachiServerCoreConfig } from "tachi-common"; // @ts-expect-error No types available... import syncFetch from "sync-fetch"; @@ -17,6 +17,9 @@ try { throw new Error(`Failed to fetch config -- ${configRes.description}.`); } } catch (err) { + const devServerUrl = import.meta.env.VITE_SERVER_URL ?? ""; + const showHttpsCertHint = devServerUrl.startsWith("https://"); + const statusHelpUrl = ToAbsoluteAPIURLForHelpLink("/status"); document.open(); document.write(`