-
- #{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();
+}
|