feat: recalc profiles endpoint, frontend rivals fix, ublock origin fix

This commit is contained in:
zk
2026-05-18 13:14:25 +00:00
parent 60c64c9874
commit e22fe8713f
17 changed files with 201 additions and 200 deletions
@@ -71,7 +71,7 @@ function DashboardLoggedIn({ user }: { user: UserDocument }) {
<UGPTProfiles />
</Route>
<Route exact path="/global-activity">
<Activity url="/activity" />
<Activity url="/ublock-blocks-this" />
</Route>
</Switch>
</div>
@@ -239,7 +239,7 @@ function DashboardNotLoggedIn() {
<div className="text-center">
Nosey? Here's what our users are up to.
<div style={{ fontSize: "1rem" }}>
<Activity url="/activity" />
<Activity url="/ublock-blocks-this" />
</div>
</div>
</Stack>
@@ -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<UserDocument>(
@@ -292,22 +295,6 @@ function PreferencesForm({
What should {TachiConfig.NAME} default to showing you about folders?
</Form.Text>
</Form.Group>
{settings.rivals.length !== 0 && (
<Form.Group className={formGroupClassNames}>
<Form.Label>Preferred Ranking</Form.Label>
<Form.Select
id="preferredRanking"
onChange={formik.handleChange}
value={formik.values.preferredRanking}
>
<option value="global">Global Rankings</option>
<option value="rival">Rival Rankings</option>
</Form.Select>
<Form.Text className="text-body-secondary">
What should {TachiConfig.NAME} default to when showing your score rankings?
</Form.Text>
</Form.Group>
)}
<Form.Group className={formGroupClassNames}>
<Form.Label>Preferred Table</Form.Label>
<Form.Select
@@ -1,101 +1,28 @@
import useLUGPTSettings from "#components/util/useLUGPTSettings";
import React from "react";
import { type integer } from "rg-stats/js/util/types";
import { type PBScoreDocument } from "tachi-common";
import { type integer, type PBScoreDocument } from "tachi-common";
import { rankingColumnTdStyle } from "./ranking-cell-layout";
export type RankingViewMode = "both-if-self" | "global" | "global-no-switch" | "rival";
const lineCls = "d-block text-truncate";
const lineCls = "d-block";
export default function RankingCell({
rankingData,
userID,
rankingViewMode,
userID: _userID,
rankingViewMode: _rankingViewMode,
}: {
rankingData: PBScoreDocument["rankingData"];
rankingViewMode: RankingViewMode;
userID: integer;
}) {
const { settings } = useLUGPTSettings();
switch (rankingViewMode) {
case "global":
case "global-no-switch": {
const title = `#${rankingData.rank} / ${rankingData.outOf}`;
return (
<td style={rankingColumnTdStyle} title={title}>
<div className={lineCls} style={{ minWidth: 0 }}>
<strong>#{rankingData.rank}</strong>
<small>/{rankingData.outOf}</small>
</div>
</td>
);
}
case "rival": {
if (!settings) {
const err =
"No Settings, yet tried to view rival stats? not possible. how'd you get here. report this.";
return (
<td style={rankingColumnTdStyle} title={err}>
<div className={lineCls} style={{ minWidth: 0, fontSize: "0.7rem" }}>
{err}
</div>
</td>
);
}
if (settings?.userID !== userID) {
return (
<td style={rankingColumnTdStyle} title="N/A">
<div className={lineCls} style={{ minWidth: 0 }}>
<strong>N/A</strong>
</div>
</td>
);
}
const denom = settings.rivals.length + 1;
const title = `#${rankingData.rivalRank} / ${denom}`;
return (
<td style={rankingColumnTdStyle} title={title}>
<div className={lineCls} style={{ minWidth: 0 }}>
<strong>#{rankingData.rivalRank}</strong>
<small>/{denom}</small>
</div>
</td>
);
}
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 (
<td style={rankingColumnTdStyle} title={fullTitle}>
<div className={lineCls} style={{ fontSize: "0.76rem", minWidth: 0 }}>
<strong>Global #{rankingData.rank}</strong>
<small>/{rankingData.outOf}</small>
</div>
<div className={lineCls} style={{ fontSize: "0.76rem", minWidth: 0 }}>
<strong>Rival #{rankingData.rivalRank}</strong>
<small>/{settings.rivals.length + 1}</small>
</div>
</td>
);
}
const title = `#${rankingData.rank} / ${rankingData.outOf}`;
return (
<td style={rankingColumnTdStyle} title={title}>
<div className={lineCls} style={{ minWidth: 0 }}>
<strong>#{rankingData.rank}</strong>
<small>/{rankingData.outOf}</small>
</div>
</td>
);
}
}
const title = `#${rankingData.rank} / ${rankingData.outOf}`;
return (
<td style={rankingColumnTdStyle} title={title}>
<div className={lineCls}>
<strong>#{rankingData.rank}</strong>
<small>/{rankingData.outOf}</small>
</div>
</td>
);
}
@@ -1,16 +1,17 @@
import type { CSSProperties } from "react";
/** Body + `<th>` 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`,
};
@@ -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<RankingViewMode>;
} & ZTableTHProps) {
const { settings } = useLUGPTSettings();
if (
rankingViewMode === "both-if-self" ||
rankingViewMode === "global-no-switch" ||
!settings ||
settings.rivals.length === 0
) {
return (
<SortableTH
changeSort={changeSort}
currentSortMode={currentSortMode}
name="Ranking"
reverseSort={reverseSort}
shortName="Ranking"
sortingName="Site Ranking"
style={rankingColumnThStyle}
/>
);
}
const sortHighlighted = currentSortMode === "Site Ranking";
return (
<th style={rankingColumnThStyle}>
<div
className="vstack gap-0 justify-content-center"
style={{ marginInline: "auto", maxWidth: "100%", minWidth: 0 }}
>
<select
aria-label="Ranking scope"
className="border-0 fw-bolder rounded focus-ring focus-ring-light bg-transparent text-body p-0"
onChange={(v) => setRankingViewMode(v.target.value as RankingViewMode)}
style={{
display: "block",
fontSize: "0.55rem",
lineHeight: 1.1,
maxWidth: "100%",
width: "100%",
}}
title={rankingViewMode === "rival" ? "Rival Ranking" : "Global Ranking"}
value={rankingViewMode}
>
<option value="global">Global</option>
<option value="rival">Rival</option>
</select>
<div
className="d-flex justify-content-center"
onClick={() => changeSort("Site Ranking")}
>
<div className="d-flex justify-content-center gap-0">
<Icon
className={
sortHighlighted && reverseSort ? "opacity-100" : "opacity-25"
}
type="arrow-up"
/>
<Icon
className={
sortHighlighted && !reverseSort ? "opacity-100" : "opacity-25"
}
type="arrow-down"
/>
</div>
</div>
</div>
</th>
<SortableTH
changeSort={changeSort}
currentSortMode={currentSortMode}
name="Ranking"
reverseSort={reverseSort}
shortName="Ranking"
sortingName="Site Ranking"
style={rankingColumnThStyle}
/>
);
}
@@ -23,9 +23,7 @@ export function CreateRankingHeader<T>(
return -Infinity;
}
return rankingViewMode === "rival"
? (rankingData.rivalRank ?? -Infinity)
: rankingData.rank;
return rankingData.rank;
}),
(thProps: ZTableTHProps) => (
<SelectableRanking
@@ -7,5 +7,7 @@ export default function usePreferredRanking():
| null {
const { settings } = useLUGPTSettings();
return settings?.preferences.preferredRanking ?? null;
const raw = settings?.preferences.preferredRanking ?? null;
// Rival ranking display is disabled; treat stored preference as global.
return raw === "rival" ? "global" : raw;
}
@@ -0,0 +1,19 @@
import { MakeAction } from "#lib/actions/actions";
import { drainGameProfileDirtyFully } from "#lib/jobs/drain-dirty-queues";
import { EnqueueAllGameProfilesDirty } from "#utils/calculations/recalc-scores";
import { IsUserAdmin } from "#utils/user";
import { ExpectedErr } from "bliss";
export const ACTION_RecalcAllGameProfiles = MakeAction(
"RECALC_ALL_GAME_PROFILES",
async (taker) => {
if (!(await IsUserAdmin(taker.acct.id))) {
throw new ExpectedErr(403, "You are not authorized to perform this action.");
}
await EnqueueAllGameProfilesDirty();
await drainGameProfileDirtyFully();
return {};
},
);
@@ -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(),
@@ -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,
@@ -243,6 +243,20 @@ export async function drainGameProfileDirty(): Promise<number> {
return rows.length;
}
/**
* Drain `game_profile_dirty` until empty (no per-tick row cap). For admin synchronous
* profile recalculation.
*/
export async function drainGameProfileDirtyFully(): Promise<void> {
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
@@ -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));
@@ -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({
@@ -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;
@@ -194,6 +194,13 @@ export const API_V1_SPEC = {
output: z.record(z.string(), doc<ActivityPayload>()),
},
"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<ActivityPayload>()),
},
// ────────────────────────────────────────────────
// 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({
@@ -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";
/**
@@ -22,3 +22,27 @@ export async function UpdateAllPBs(): Promise<void> {
.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<void> {
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();
}