From e22fe8713f2037eb599c2c4a7b85caa638ebb0dc Mon Sep 17 00:00:00 2001 From: zk Date: Mon, 18 May 2026 13:14:25 +0000 Subject: [PATCH] feat: recalc profiles endpoint, frontend rivals fix, ublock origin fix --- .../src/app/pages/dashboard/DashboardPage.tsx | 4 +- .../_game/_playtype/UGPTSettingsPage.tsx | 21 +--- .../components/tables/cells/RankingCell.tsx | 99 +++---------------- .../tables/cells/ranking-cell-layout.ts | 9 +- .../tables/components/SelectableRanking.tsx | 82 +++------------ .../tables/headers/RankingHeader.tsx | 4 +- .../components/util/usePreferredRanking.tsx | 4 +- .../src/actions/recalc-all-game-profiles.ts | 19 ++++ typescript/server/src/lib/actions/actions.ts | 4 + .../actions/authenticated-action-handlers.ts | 2 + .../server/src/lib/jobs/drain-dirty-queues.ts | 14 +++ .../server/router/api/v1/activity/router.ts | 29 +++--- .../server/router/api/v1/admin/router.test.ts | 52 ++++++++++ .../src/server/router/api/v1/admin/router.ts | 14 +++ .../server/src/server/router/api/v1/spec.ts | 14 +++ .../games/_game/_playtype/sessions/router.ts | 6 +- .../src/utils/calculations/recalc-scores.ts | 24 +++++ 17 files changed, 201 insertions(+), 200 deletions(-) create mode 100644 typescript/server/src/actions/recalc-all-game-profiles.ts diff --git a/typescript/client/src/app/pages/dashboard/DashboardPage.tsx b/typescript/client/src/app/pages/dashboard/DashboardPage.tsx index 159f55b40..ae8aa3d9a 100644 --- a/typescript/client/src/app/pages/dashboard/DashboardPage.tsx +++ b/typescript/client/src/app/pages/dashboard/DashboardPage.tsx @@ -71,7 +71,7 @@ function DashboardLoggedIn({ user }: { user: UserDocument }) { - + @@ -239,7 +239,7 @@ function DashboardNotLoggedIn() {
Nosey? Here's what our users are up to.
- +
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 439d69bb9..685ef69c6 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 @@ -168,7 +168,10 @@ function PreferencesForm({ defaultTable: settings.preferences.defaultTable, preferredDefaultEnum: settings.preferences.preferredDefaultEnum ?? gameConfig.preferredDefaultEnum, - preferredRanking: settings.preferences.preferredRanking ?? "global", + preferredRanking: + settings.preferences.preferredRanking === "rival" + ? "global" + : (settings.preferences.preferredRanking ?? "global"), }, onSubmit: async (values) => { const rj = await APIFetchV1( @@ -292,22 +295,6 @@ function PreferencesForm({ What should {TachiConfig.NAME} default to showing you about folders? - {settings.rivals.length !== 0 && ( - - Preferred Ranking - - - - - - What should {TachiConfig.NAME} default to when showing your score rankings? - - - )} Preferred Table -
- #{rankingData.rank} - /{rankingData.outOf} -
- - ); - } - case "rival": { - if (!settings) { - const err = - "No Settings, yet tried to view rival stats? not possible. how'd you get here. report this."; - return ( - -
- {err} -
- - ); - } - - if (settings?.userID !== userID) { - return ( - -
- N/A -
- - ); - } - - const denom = settings.rivals.length + 1; - const title = `#${rankingData.rivalRank} / ${denom}`; - return ( - -
- #{rankingData.rivalRank} - /{denom} -
- - ); - } - - case "both-if-self": { - if (settings?.userID === userID && rankingData.rivalRank !== null) { - const globalTitle = `Global #${rankingData.rank} / ${rankingData.outOf}`; - const rivalTitle = `Rival #${rankingData.rivalRank} / ${settings.rivals.length + 1}`; - const fullTitle = `${globalTitle} — ${rivalTitle}`; - return ( - -
- Global #{rankingData.rank} - /{rankingData.outOf} -
-
- Rival #{rankingData.rivalRank} - /{settings.rivals.length + 1} -
- - ); - } - - const title = `#${rankingData.rank} / ${rankingData.outOf}`; - return ( - -
- #{rankingData.rank} - /{rankingData.outOf} -
- - ); - } - } + const title = `#${rankingData.rank} / ${rankingData.outOf}`; + return ( + +
+ #{rankingData.rank} + /{rankingData.outOf} +
+ + ); } diff --git a/typescript/client/src/components/tables/cells/ranking-cell-layout.ts b/typescript/client/src/components/tables/cells/ranking-cell-layout.ts index f49d8e9c0..1927b6a04 100644 --- a/typescript/client/src/components/tables/cells/ranking-cell-layout.ts +++ b/typescript/client/src/components/tables/cells/ranking-cell-layout.ts @@ -1,16 +1,17 @@ import type { CSSProperties } from "react"; /** Body + `` must agree or `auto` tables size the column from the widest header cell. */ -export const RANKING_CELL_WIDTH_PX = 50; +export const RANKING_CELL_WIDTH_PX = 104; const rankingColumnBox: Pick< CSSProperties, - "boxSizing" | "maxWidth" | "minWidth" | "overflow" | "width" + "boxSizing" | "maxWidth" | "minWidth" | "overflow" | "whiteSpace" | "width" > = { boxSizing: "border-box", - maxWidth: `${RANKING_CELL_WIDTH_PX}px`, + maxWidth: "none", minWidth: `${RANKING_CELL_WIDTH_PX}px`, - overflow: "hidden", + overflow: "visible", + whiteSpace: "nowrap", width: `${RANKING_CELL_WIDTH_PX}px`, }; diff --git a/typescript/client/src/components/tables/components/SelectableRanking.tsx b/typescript/client/src/components/tables/components/SelectableRanking.tsx index f5b39d6fe..c9cc2d4c6 100644 --- a/typescript/client/src/components/tables/components/SelectableRanking.tsx +++ b/typescript/client/src/components/tables/components/SelectableRanking.tsx @@ -1,5 +1,3 @@ -import Icon from "#components/util/Icon"; -import useLUGPTSettings from "#components/util/useLUGPTSettings"; import { type SetState } from "#types/react"; import React from "react"; @@ -8,9 +6,10 @@ import { type RankingViewMode } from "../cells/RankingCell"; import SortableTH from "./SortableTH"; import { type ZTableTHProps } from "./TachiTable"; +/** Rival ranking scope is temporarily disabled; header is always a single “Ranking” column. */ export default function SelectableRanking({ - rankingViewMode, - setRankingViewMode, + rankingViewMode: _rankingViewMode, + setRankingViewMode: _setRankingViewMode, changeSort, currentSortMode, reverseSort, @@ -18,72 +17,15 @@ export default function SelectableRanking({ rankingViewMode: RankingViewMode; setRankingViewMode: SetState; } & ZTableTHProps) { - const { settings } = useLUGPTSettings(); - - if ( - rankingViewMode === "both-if-self" || - rankingViewMode === "global-no-switch" || - !settings || - settings.rivals.length === 0 - ) { - return ( - - ); - } - - const sortHighlighted = currentSortMode === "Site Ranking"; - return ( - -
- -
changeSort("Site Ranking")} - > -
- - -
-
-
- + ); } diff --git a/typescript/client/src/components/tables/headers/RankingHeader.tsx b/typescript/client/src/components/tables/headers/RankingHeader.tsx index ce58bc24b..93083d9d2 100644 --- a/typescript/client/src/components/tables/headers/RankingHeader.tsx +++ b/typescript/client/src/components/tables/headers/RankingHeader.tsx @@ -23,9 +23,7 @@ export function CreateRankingHeader( return -Infinity; } - return rankingViewMode === "rival" - ? (rankingData.rivalRank ?? -Infinity) - : rankingData.rank; + return rankingData.rank; }), (thProps: ZTableTHProps) => ( { + if (!(await IsUserAdmin(taker.acct.id))) { + throw new ExpectedErr(403, "You are not authorized to perform this action."); + } + + await EnqueueAllGameProfilesDirty(); + await drainGameProfileDirtyFully(); + + return {}; + }, +); diff --git a/typescript/server/src/lib/actions/actions.ts b/typescript/server/src/lib/actions/actions.ts index e5e398504..9a5f8495c 100644 --- a/typescript/server/src/lib/actions/actions.ts +++ b/typescript/server/src/lib/actions/actions.ts @@ -298,6 +298,10 @@ export const ActionSignatures = { rowCount: z.number().int(), }), }, + RECALC_ALL_GAME_PROFILES: { + input: z.object({}), + output: z.object({}), + }, IMPORT_SEEDS: { input: z.object({ commitHash: z.string(), diff --git a/typescript/server/src/lib/actions/authenticated-action-handlers.ts b/typescript/server/src/lib/actions/authenticated-action-handlers.ts index 5ee1ca309..d183361f7 100644 --- a/typescript/server/src/lib/actions/authenticated-action-handlers.ts +++ b/typescript/server/src/lib/actions/authenticated-action-handlers.ts @@ -29,6 +29,7 @@ import { ACTION_ImportSeeds } from "#actions/import-seeds"; import { ACTION_InstallBuiltinClient } from "#actions/install-builtin-client"; import { ACTION_MarkAllNotificationsRead } from "#actions/mark-all-notifications-read"; import { ACTION_PatchUGPTSettings } from "#actions/patch-ugpt-settings"; +import { ACTION_RecalcAllGameProfiles } from "#actions/recalc-all-game-profiles"; import { ACTION_RebuildFolderChartLookup } from "#actions/rebuild-folder-chart-lookup"; import { ACTION_RemoveGoalSubscription } from "#actions/remove-goal-subscription"; import { ACTION_ResendVerifyEmail } from "#actions/resend-verify-email"; @@ -93,6 +94,7 @@ export const authenticatedActionHandlers = { MARK_ALL_NOTIFICATIONS_READ: ACTION_MarkAllNotificationsRead, PATCH_UGPT_SETTINGS: ACTION_PatchUGPTSettings, REBUILD_FOLDER_CHART_LOOKUP: ACTION_RebuildFolderChartLookup, + RECALC_ALL_GAME_PROFILES: ACTION_RecalcAllGameProfiles, REMOVE_GOAL_SUBSCRIPTION: ACTION_RemoveGoalSubscription, RESEND_VERIFY_EMAIL: ACTION_ResendVerifyEmail, RESET_API_CLIENT_SECRET: ACTION_ResetApiClientSecret, diff --git a/typescript/server/src/lib/jobs/drain-dirty-queues.ts b/typescript/server/src/lib/jobs/drain-dirty-queues.ts index 5c7dd7ec0..ffc36c1af 100644 --- a/typescript/server/src/lib/jobs/drain-dirty-queues.ts +++ b/typescript/server/src/lib/jobs/drain-dirty-queues.ts @@ -243,6 +243,20 @@ export async function drainGameProfileDirty(): Promise { return rows.length; } +/** + * Drain `game_profile_dirty` until empty (no per-tick row cap). For admin synchronous + * profile recalculation. + */ +export async function drainGameProfileDirtyFully(): Promise { + while (true) { + const n = await drainGameProfileDirty(); + + if (n === 0) { + break; + } + } +} + /** * Drain `score_rederive`, then `pb_dirty`, then `session_dirty`, then `game_profile_dirty`, * repeating until a full pass does nothing. Each queue has its own per-tick row budget so diff --git a/typescript/server/src/server/router/api/v1/activity/router.ts b/typescript/server/src/server/router/api/v1/activity/router.ts index fc0b70a94..d4423face 100644 --- a/typescript/server/src/server/router/api/v1/activity/router.ts +++ b/typescript/server/src/server/router/api/v1/activity/router.ts @@ -1,24 +1,29 @@ import { GetRecentActivityForMultipleGames } from "#lib/activity/activity"; +import { success } from "#lib/router/typed-router"; import { ALL_GAMES } from "tachi-common"; import { API_V1_ROUTER } from "../router"; +async function globalActivityImpl(input: { startTime?: number }) { + const data = await GetRecentActivityForMultipleGames( + ALL_GAMES, + undefined, + input.startTime ?? null, + ); + + return success(`Returned global activity.`, data); +} + /** * Retrieve *all* activity across every game on the site. * * @param session - See CreateActivityRouteHandler * @param startTime - See CreateActivityRouteHandler */ -API_V1_ROUTER.add("GET /activity", async ({ input }) => { - const data = await GetRecentActivityForMultipleGames( - ALL_GAMES, - undefined, - input.startTime ?? null, - ); +API_V1_ROUTER.add("GET /activity", ({ input }) => globalActivityImpl(input)); - return { - success: true, - description: `Returned global activity.`, - body: data, - }; -}); +/** + * Same behavior as `GET /activity`. Some browser blocklists match `/activity`; + * use this path when an ad blocker interferes. + */ +API_V1_ROUTER.add("GET /ublock-blocks-this", ({ input }) => globalActivityImpl(input)); diff --git a/typescript/server/src/server/router/api/v1/admin/router.test.ts b/typescript/server/src/server/router/api/v1/admin/router.test.ts index 88acbed30..663b53243 100644 --- a/typescript/server/src/server/router/api/v1/admin/router.test.ts +++ b/typescript/server/src/server/router/api/v1/admin/router.test.ts @@ -194,6 +194,58 @@ describe("POST /api/v1/admin/recalc", () => { }); }); +describe("POST /api/v1/admin/recalc-profiles", () => { + it("returns 403 when the caller is not an admin", async () => { + await seedUser({ + username: "prof_pleb", + email: "prof_pleb@test.com", + withCredential: true, + withSettings: true, + }); + + const plebCookie = await loginAs("prof_pleb"); + + const res = await mockApi + .post("/api/v1/admin/recalc-profiles") + .set("Cookie", plebCookie) + .send({}); + + expect(res.status).toBe(403); + }); + + it("drains game_profile_dirty when the caller is an admin", async () => { + const { id: adminId } = await seedUser({ + username: "prof_admin", + email: "prof_admin@test.com", + authLevel: "admin", + withCredential: true, + withSettings: true, + }); + + const adminCookie = await loginAs("prof_admin"); + + await DB.insertInto("game_profile_dirty") + .values({ user_id: adminId, game: "iidx-sp" }) + .onConflict((oc) => oc.doNothing()) + .execute(); + + const res = await mockApi + .post("/api/v1/admin/recalc-profiles") + .set("Cookie", adminCookie) + .send({}); + + expect(res.status).toBe(200); + + const stillQueued = await DB.selectFrom("game_profile_dirty") + .select("game_profile_dirty.user_id") + .where("game_profile_dirty.user_id", "=", adminId) + .where("game_profile_dirty.game", "=", "iidx-sp") + .executeTakeFirst(); + + expect(stillQueued).toBeUndefined(); + }); +}); + describe("POST /api/v1/admin/recalc-pbs", () => { it("returns 403 when the caller is not an admin", async () => { await seedUser({ diff --git a/typescript/server/src/server/router/api/v1/admin/router.ts b/typescript/server/src/server/router/api/v1/admin/router.ts index 8e8e24abf..94ce3cef9 100644 --- a/typescript/server/src/server/router/api/v1/admin/router.ts +++ b/typescript/server/src/server/router/api/v1/admin/router.ts @@ -1,6 +1,7 @@ import { ACTION_DeleteScore } from "#actions/delete-score"; import { ACTION_DeleteSession } from "#actions/delete-session"; import { ACTION_RebuildFolderChartLookup } from "#actions/rebuild-folder-chart-lookup"; +import { ACTION_RecalcAllGameProfiles } from "#actions/recalc-all-game-profiles"; import { ACTION_SetUserQuestSubmitterStatus } from "#actions/set-user-quest-submitter-status"; import { ACTION_SetUserSupporterStatus } from "#actions/set-user-supporter-status"; import { @@ -126,6 +127,19 @@ API_V1_ROUTER.add("POST /admin/recalc", withAdmin, async () => { ); }); +API_V1_ROUTER.add("POST /admin/recalc-profiles", withAdmin, async ({ req }) => { + const adminUserID = req[SYMBOL_TACHI_API_AUTH].userID!; + const adminUser = await GetUserWithIDGuaranteed(adminUserID); + const taker = { acct: { id: adminUser.id, username: adminUser.username }, ip: req.ip }; + + await ACTION_RecalcAllGameProfiles(taker, {}); + + return success( + "Enqueued all game_profile rows and distinct committed score (user, game) pairs into game_profile_dirty, then drained that queue until idle.", + {}, + ); +}); + API_V1_ROUTER.add("POST /admin/announcement", withAdmin, async ({ input }) => { const game = input.game as V3Game | undefined; diff --git a/typescript/server/src/server/router/api/v1/spec.ts b/typescript/server/src/server/router/api/v1/spec.ts index 60492f1a4..888a6f2c7 100644 --- a/typescript/server/src/server/router/api/v1/spec.ts +++ b/typescript/server/src/server/router/api/v1/spec.ts @@ -194,6 +194,13 @@ export const API_V1_SPEC = { output: z.record(z.string(), doc()), }, + "GET /ublock-blocks-this": { + description: + "Global recent activity across all games (alias of GET /activity for clients affected by blocklists that match `/activity`).", + input: z.object({ startTime: z.coerce.number().optional() }), + output: z.record(z.string(), doc()), + }, + // ──────────────────────────────────────────────── // Search // ──────────────────────────────────────────────── @@ -1987,6 +1994,13 @@ export const API_V1_SPEC = { output: empty, }, + "POST /admin/recalc-profiles": { + description: + "Enqueue every `game_profile` row and every distinct committed `(user_id, game)` from `score` into `game_profile_dirty`, then synchronously drain that queue until idle (recomputes ratings/classes from current PBs). No request body.", + input: z.object({}), + output: empty, + }, + "POST /admin/announcement": { description: "Create a site announcement.", input: z.object({ diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts index 9e5fc3348..12b5ad937 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts @@ -14,11 +14,7 @@ import { GetScoreIdsGroupedBySessionId } from "#utils/queries/sessions"; import { CheckStrSessionAlg } from "#utils/string-checks"; import { ExpectedErr } from "bliss"; import { sql } from "kysely"; -import { - type AnySessionRatingAlg, - GetGameConfig, - LEGACY_GameToGameGroupPT, -} from "tachi-common"; +import { type AnySessionRatingAlg, GetGameConfig, LEGACY_GameToGameGroupPT } from "tachi-common"; import { type Game } from "tachi-db"; /** diff --git a/typescript/server/src/utils/calculations/recalc-scores.ts b/typescript/server/src/utils/calculations/recalc-scores.ts index f58272acf..a0fd31ae9 100644 --- a/typescript/server/src/utils/calculations/recalc-scores.ts +++ b/typescript/server/src/utils/calculations/recalc-scores.ts @@ -22,3 +22,27 @@ export async function UpdateAllPBs(): Promise { .onConflict((oc) => oc.doNothing()) .execute(); } + +/** + * Enqueue every existing `game_profile` row and every distinct (user, playtype) with a + * committed score into `game_profile_dirty` so workers (or admin drain) will recompute + * ratings/classes from current PBs. + */ +export async function EnqueueAllGameProfilesDirty(): Promise { + await DB.insertInto("game_profile_dirty") + .expression( + DB.selectFrom("game_profile").select(["game_profile.user_id", "game_profile.game"]), + ) + .onConflict((oc) => oc.doNothing()) + .execute(); + + await DB.insertInto("game_profile_dirty") + .expression( + DB.selectFrom("score") + .select(["score.user_id", "score.game"]) + .where("score.committed", "=", true) + .distinct(), + ) + .onConflict((oc) => oc.doNothing()) + .execute(); +}