impr(leaderboard): daily mode/language buttons based on backend configuration (@fehmer) (#6713)

Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
Christian Fehmer 2025-07-14 15:32:09 +02:00 committed by GitHub
parent 5aec2c9a17
commit a4de8dfda6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 326 additions and 112 deletions

View file

@ -139,28 +139,29 @@ describe("Loaderboard Controller", () => {
);
});
it("should get for mode", async () => {
getLeaderboardMock.mockResolvedValue([]);
for (const mode of ["time", "words", "quote", "zen", "custom"]) {
const response = await mockApp
.get("/leaderboards")
.query({ language: "english", mode, mode2: "custom" });
expect(response.status, "for mode " + mode).toEqual(200);
}
describe("should get for modes", async () => {
beforeEach(() => {
getLeaderboardMock.mockResolvedValue([]);
});
const testCases = [
{ mode: "time", mode2: "15", language: "english", expectStatus: 200 },
{ mode: "time", mode2: "60", language: "english", expectStatus: 200 },
{ mode: "time", mode2: "30", language: "english", expectStatus: 404 },
{ mode: "words", mode2: "15", language: "english", expectStatus: 404 },
{ mode: "time", mode2: "15", language: "spanish", expectStatus: 404 },
];
it.for(testCases)(
`expect $expectStatus for mode $mode, mode2 $mode2, lang $language`,
async ({ mode, mode2, language, expectStatus }) => {
await mockApp
.get("/leaderboards")
.query({ language, mode, mode2 })
.expect(expectStatus);
}
);
});
it("should get for mode2", async () => {
getLeaderboardMock.mockResolvedValue([]);
for (const mode2 of allModes) {
const response = await mockApp.get("/leaderboards").query({
language: "english",
mode: "words",
mode2,
});
expect(response.status, "for mode2 " + mode2).toEqual(200);
}
});
it("fails for missing query", async () => {
const { body } = await mockApp.get("/leaderboards").expect(422);

View file

@ -32,6 +32,14 @@ export async function getLeaderboard(
): Promise<GetLeaderboardResponse> {
const { language, mode, mode2, page, pageSize } = req.query;
if (
mode !== "time" ||
(mode2 !== "15" && mode2 !== "60") ||
language !== "english"
) {
throw new MonkeyError(404, "There is no leaderboard for this mode");
}
const leaderboard = await LeaderboardsDAL.get(
mode,
mode2,

View file

@ -156,7 +156,7 @@
</div>
</div>
</div>
<div class="buttons">
<div class="sideButtons">
<div class="buttonGroup typeButtons">
<button data-type="allTime">
<i class="fas fa-globe-americas"></i>
@ -172,7 +172,7 @@
</button>
</div>
<div class="divider hidden"></div>
<div class="buttonGroup hidden secondary modeButtons">
<div class="buttonGroup hidden modeButtons">
<button data-mode="15">
<i class="fas fa-clock"></i>
time 15
@ -182,8 +182,8 @@
time 60
</button>
</div>
<div class="divider divider2 hidden"></div>
<div class="buttonGroup hidden secondary languageButtons">
<div class="divider2 hidden"></div>
<div class="buttonGroup hidden languageButtons">
<button data-language="english">
<i class="fas fa-globe"></i>
english

View file

@ -279,7 +279,7 @@
color: var(--sub-color);
padding: 1em;
}
.buttons {
.sideButtons {
align-content: start;
align-items: start;
grid-area: buttons;
@ -294,7 +294,8 @@
justify-content: start;
padding-left: 0.75em;
}
.divider {
.divider,
.divider2 {
background: var(--bg-color);
width: 100%;
height: 0.25em;

View file

@ -1,8 +1,13 @@
import { Configuration } from "@monkeytype/contracts/schemas/configuration";
import Ape from ".";
import { promiseWithResolvers } from "../utils/misc";
let config: Configuration | undefined = undefined;
const { promise: configPromise, resolve } = promiseWithResolvers<boolean>();
export { configPromise };
export function get(): Configuration | undefined {
return config;
}
@ -15,5 +20,6 @@ export async function sync(): Promise<void> {
return;
} else {
config = response.body.data ?? undefined;
resolve(true);
}
}

View file

@ -45,6 +45,7 @@ import {
LanguageSchema,
} from "@monkeytype/contracts/schemas/languages";
import { isSafeNumber } from "@monkeytype/util/numbers";
import { Mode, Mode2, ModeSchema } from "@monkeytype/contracts/schemas/shared";
import * as ServerConfiguration from "../ape/server-configuration";
const LeaderboardTypeSchema = z.enum(["allTime", "weekly", "daily"]);
@ -56,6 +57,7 @@ type AllTimeState = {
type: "allTime";
mode: "time";
mode2: "15" | "60";
language: "english";
data: LeaderboardEntry[] | null;
count: number;
userData: LeaderboardEntry | null;
@ -71,8 +73,8 @@ type WeeklyState = {
type DailyState = {
type: "daily";
mode: "time";
mode2: "15" | "60";
mode: Mode;
mode2: Mode2<DailyState["mode"]>;
yesterday: boolean;
minWpm: number;
language: Language;
@ -98,6 +100,7 @@ const state = {
loading: true,
updating: false,
type: "allTime",
mode: "time",
mode2: "15",
data: null,
userData: null,
@ -111,7 +114,8 @@ const state = {
const SelectorSchema = z.object({
type: LeaderboardTypeSchema,
mode2: z.enum(["15", "60"]).optional(),
mode: ModeSchema.optional(),
mode2: z.string().optional(),
language: LanguageSchema.optional(),
yesterday: z.boolean().optional(),
lastWeek: z.boolean().optional(),
@ -128,6 +132,25 @@ const selectorLS = new LocalStorageWithSchema({
fallback: { type: "allTime", mode2: "15" },
});
type LanguagesByModeByMode2 = Partial<
Record<Mode, Record<string /*mode2*/, Language[]>>
>;
type ValidLeaderboards = {
allTime: LanguagesByModeByMode2;
daily: LanguagesByModeByMode2;
};
const validLeaderboards: ValidLeaderboards = {
allTime: {
time: {
"15": ["english"],
"60": ["english"],
},
},
daily: {},
};
function updateTitle(): void {
const type =
state.type === "allTime"
@ -137,17 +160,11 @@ function updateTitle(): void {
: "Daily";
const language =
state.type === "daily"
? capitalizeFirstLetter(state.language)
: state.type === "allTime"
? "English"
: "";
state.type !== "weekly" ? capitalizeFirstLetter(state.language) : "";
const mode =
state.type === "allTime"
? ` Time ${state.mode2}`
: state.type === "daily"
? ` Time ${state.mode2}`
state.type !== "weekly"
? ` ${capitalizeFirstLetter(state.mode)} ${state.mode2}`
: "";
state.title = `${type} ${language} ${mode} Leaderboard`;
@ -273,7 +290,7 @@ async function requestData(update = false): Promise<void> {
Ape.leaderboards.getDailyRank,
{
language: state.language,
mode: "time",
mode: state.mode,
mode2: state.mode2,
daysBefore: state.yesterday ? 1 : undefined,
}
@ -326,11 +343,16 @@ async function requestData(update = false): Promise<void> {
}
} else {
state.data = null;
state.error = "Something went wrong";
Notifications.add(
"Failed to get leaderboard: " + dataResponse.body.message,
-1
);
if (dataResponse.status === 404) {
state.error = "No leaderboard found";
} else {
state.error = "Something went wrong";
Notifications.add(
"Failed to get leaderboard: " + dataResponse.body.message,
-1
);
}
}
if (state.userData === null && rankResponse !== undefined) {
@ -919,34 +941,77 @@ function updateContent(): void {
}
}
function updateSideButtons(): void {
updateTypeButtons();
updateModeButtons();
updateLanguageButtons();
}
function updateTypeButtons(): void {
const el = $(".page.pageLeaderboards .buttonGroup.typeButtons");
el.find("button").removeClass("active");
el.find(`button[data-type=${state.type}]`).addClass("active");
}
function updateSecondaryButtons(): void {
$(".page.pageLeaderboards .buttonGroup.secondary").addClass("hidden");
$(".page.pageLeaderboards .buttons .divider").addClass("hidden");
$(".page.pageLeaderboards .buttons .divider2").addClass("hidden");
if (state.type === "allTime") {
$(".page.pageLeaderboards .buttonGroup.modeButtons").removeClass("hidden");
$(".page.pageLeaderboards .buttons .divider").removeClass("hidden");
$(".page.pageLeaderboards .buttons .divider2").addClass("hidden");
updateModeButtons();
function updateModeButtons(): void {
if (state.type !== "allTime" && state.type !== "daily") {
$(".page.pageLeaderboards .buttonGroup.modeButtons").addClass("hidden");
$(".page.pageLeaderboards .sideButtons .divider").addClass("hidden");
return;
}
if (state.type === "daily") {
$(".page.pageLeaderboards .buttonGroup.modeButtons").removeClass("hidden");
$(".page.pageLeaderboards .buttonGroup.languageButtons").removeClass(
"hidden"
);
$(".page.pageLeaderboards .buttons .divider").removeClass("hidden");
$(".page.pageLeaderboards .buttons .divider2").removeClass("hidden");
$(".page.pageLeaderboards .buttonGroup.modeButtons").removeClass("hidden");
$(".page.pageLeaderboards .sideButtons .divider").removeClass("hidden");
updateModeButtons();
updateLanguageButtons();
const el = $(".page.pageLeaderboards .buttonGroup.modeButtons");
el.find("button").removeClass("active");
el.find(
`button[data-mode=${state.mode}][data-mode2=${state.mode2}]`
).addClass("active");
//hide all mode buttons
$(`.page.pageLeaderboards .buttonGroup.modeButtons button`).addClass(
"hidden"
);
//show all valid ones
for (const mode of Object.keys(validLeaderboards[state.type]) as Mode[]) {
for (const mode2 of Object.keys(
// oxlint-disable-next-line no-non-null-assertion
validLeaderboards[state.type][mode]!
)) {
$(
`.page.pageLeaderboards .buttonGroup.modeButtons button[data-mode="${mode}"][data-mode2="${mode2}"]`
).removeClass("hidden");
}
}
}
function updateLanguageButtons(): void {
if (state.type !== "daily") {
$(".page.pageLeaderboards .buttonGroup.languageButtons").addClass("hidden");
$(".page.pageLeaderboards .sideButtons .divider2").addClass("hidden");
return;
}
$(".page.pageLeaderboards .buttonGroup.languageButtons").removeClass(
"hidden"
);
$(".page.pageLeaderboards .sideButtons .divider2").removeClass("hidden");
const el = $(".page.pageLeaderboards .buttonGroup.languageButtons");
el.find("button").removeClass("active");
el.find(`button[data-language=${state.language}]`).addClass("active");
//hide all languages
$(`.page.pageLeaderboards .buttonGroup.languageButtons button`).addClass(
"hidden"
);
//show all valid ones
for (const lang of validLeaderboards[state.type][state.mode]?.[state.mode2] ??
[]) {
$(
`.page.pageLeaderboards .buttonGroup.languageButtons button[data-language="${lang}"]`
).removeClass("hidden");
}
}
@ -1009,37 +1074,142 @@ function stopTimer(): void {
$(".page.pageLeaderboards .titleAndButtons .timer").text("-");
}
// async function appendLanguageButtons(): Promise<void> {
// const languages =
// (await ServerConfiguration.get()?.dailyLeaderboards.validModeRules.map(
// (r) => r.language
// )) ?? [];
// const el = $(".page.pageLeaderboards .buttonGroup.languageButtons");
// el.empty();
// for (const language of languages) {
// el.append(`
// <button data-language="${language}">
// <i class="fas fa-globe"></i>
// ${language}
// </button>
// `);
// }
// }
function updateModeButtons(): void {
if (state.type !== "allTime" && state.type !== "daily") return;
const el = $(".page.pageLeaderboards .buttonGroup.modeButtons");
el.find("button").removeClass("active");
el.find(`button[data-mode=${state.mode2}]`).addClass("active");
function convertRuleOption(rule: string): string[] {
if (rule.startsWith("(")) {
return rule.slice(1, -1).split("|");
}
return [rule];
}
function updateLanguageButtons(): void {
if (state.type !== "daily") return;
const el = $(".page.pageLeaderboards .buttonGroup.languageButtons");
el.find("button").removeClass("active");
el.find(`button[data-language=${state.language}]`).addClass("active");
async function updateValidDailyLeaderboards(): Promise<void> {
const dailyRulesConfig = await ServerConfiguration.get()?.dailyLeaderboards
.validModeRules;
if (dailyRulesConfig === undefined) {
throw new Error(
"cannot load server configuration for dailyLeaderboards.validModeRules"
);
}
//a rule can contain multiple values. create a flat list out of them
const dailyRules = dailyRulesConfig.flatMap((rule) => {
const languages = convertRuleOption(rule.language) as Language[];
const mode2List = convertRuleOption(rule.mode2);
return mode2List.map((mode2) => ({
mode: rule.mode as Mode,
mode2,
languages,
}));
});
validLeaderboards.daily = dailyRules.reduce<
Partial<Record<Mode, Record<string /*mode2*/, Language[]>>>
>((acc, { mode, mode2, languages }) => {
let modes = acc[mode];
if (modes === undefined) {
modes = {};
acc[mode] = modes;
}
let modes2 = modes[mode2];
if (modes2 === undefined) {
modes2 = [];
modes[mode2] = modes2;
}
modes2.push(...languages);
return acc;
}, {});
}
function checkIfLeaderboardIsValid(): void {
if (state.type === "weekly") return;
const validLeaderboard = validLeaderboards[state.type];
let validModes2 = validLeaderboard[state.mode];
if (validModes2 === undefined) {
const firstMode = Object.keys(validLeaderboard).sort()[0] as Mode;
if (firstMode === undefined) {
throw new Error(`no valid leaderboard config for type ${state.type}`);
}
state.mode = firstMode;
// oxlint-disable-next-line no-non-null-assertion
validModes2 = validLeaderboard[state.mode]!;
}
let supportedLanguages = validModes2[state.mode2];
if (supportedLanguages === undefined) {
const firstMode2 = Object.keys(validModes2).sort(
(a, b) => parseInt(a) - parseInt(b)
)[0];
if (firstMode2 === undefined) {
throw new Error(
`no valid leaderboard config for type ${state.type} and mode ${state.mode}`
);
}
state.mode2 = firstMode2;
supportedLanguages = validModes2[state.mode2];
}
if (supportedLanguages === undefined || supportedLanguages.length < 1) {
throw new Error(
`Daily leaderboard config not valid for mode:${state.mode} mode2:${state.mode2}`
);
}
if (!supportedLanguages.includes(state.language)) {
state.language = supportedLanguages.sort()[0] as Language;
}
}
async function appendModeAndLanguageButtons(): Promise<void> {
const modes = Array.from(
new Set(
Object.values(validLeaderboards).flatMap(
(rule) => Object.keys(rule) as Mode[]
)
)
).sort();
const mode2Buttons = modes.flatMap((mode) => {
const modes2 = Array.from(
new Set(
Object.values(validLeaderboards).flatMap((rule) =>
Object.keys(rule[mode] ?? {})
)
)
).sort((a, b) => parseInt(a) - parseInt(b));
const icon = mode === "time" ? "fas fa-clock" : "fas fa-align-left";
return modes2.map(
(mode2) => `<button data-mode="${mode}" data-mode2="${mode2}">
<i class="${icon}"></i>
${mode} ${mode2}
</button>`
);
});
$(".modeButtons").html(mode2Buttons.join("\n"));
const availableLanguages = Array.from(
new Set(
Object.values(validLeaderboards)
.flatMap((rule) => Object.values(rule))
.flatMap((mode) => Object.values(mode))
.flatMap((it) => it)
)
).sort();
const languageButtons = availableLanguages.map(
(lang) =>
`<button data-language="${lang}">
<i class="fas fa-globe"></i>
${lang}
</button>`
);
$(".languageButtons").html(languageButtons.join("\n"));
}
function disableButtons(): void {
@ -1126,6 +1296,7 @@ function updateGetParameters(): void {
if (state.type === "allTime") {
params.mode2 = state.mode2;
} else if (state.type === "daily") {
params.mode = state.mode;
params.language = state.language;
params.mode2 = state.mode2;
if (state.yesterday) {
@ -1155,19 +1326,19 @@ function readGetParameters(params?: UrlParameter): void {
}
if (state.type === "allTime") {
if (params.mode2) {
state.mode2 = params.mode2;
if (params.mode2 !== undefined) {
state.mode2 = params.mode2 as AllTimeState["mode2"];
}
} else if (state.type === "daily") {
if (params.language !== undefined) {
state.language = params.language;
}
if (state.language === undefined) {
state.language = "english";
}
if (params.mode2 !== undefined) {
state.mode2 = params.mode2;
}
if (params.mode !== undefined) {
state.mode = params.mode;
}
if (params.yesterday !== undefined) {
state.yesterday = params.yesterday;
}
@ -1184,7 +1355,7 @@ function readGetParameters(params?: UrlParameter): void {
state.page = 0;
}
}
if (params.goToUserPage) {
if (params.goToUserPage === true) {
state.goToUserPage = true;
}
}
@ -1235,40 +1406,63 @@ $(".page.pageLeaderboards .buttonGroup.typeButtons").on(
if (state.type === "weekly") {
state.lastWeek = false;
}
checkIfLeaderboardIsValid();
state.data = null;
state.page = 0;
void requestData();
updateTypeButtons();
updateTitle();
updateSecondaryButtons();
updateSideButtons();
updateContent();
updateGetParameters();
}
);
$(".page.pageLeaderboards .buttonGroup.secondary").on(
$(".page.pageLeaderboards .buttonGroup.modeButtons").on(
"click",
"button",
function () {
const mode = $(this).attr("data-mode") as "15" | "60" | undefined;
const language = $(this).data("language") as Language;
const mode = $(this).attr("data-mode") as Mode;
const mode2 = $(this).attr("data-mode2");
if (
mode !== undefined &&
mode2 !== undefined &&
(state.type === "allTime" || state.type === "daily")
) {
if (state.mode2 === mode) return;
state.mode2 = mode;
if (state.mode === mode && state.mode2 === mode2) return;
state.mode = mode;
state.mode2 = mode2;
state.page = 0;
} else if (language !== undefined && state.type === "daily") {
} else {
return;
}
checkIfLeaderboardIsValid();
state.data = null;
void requestData();
updateSideButtons();
updateTitle();
updateContent();
updateGetParameters();
}
);
$(".page.pageLeaderboards .buttonGroup.languageButtons").on(
"click",
"button",
function () {
const language = $(this).attr("data-language") as Language;
if (language !== undefined && state.type === "daily") {
if (state.language === language) return;
state.language = language;
state.page = 0;
} else {
return;
}
checkIfLeaderboardIsValid();
state.data = null;
void requestData();
updateSecondaryButtons();
updateSideButtons();
updateTitle();
updateContent();
updateGetParameters();
@ -1286,19 +1480,21 @@ export const page = new PageWithUrlParams({
stopTimer();
},
beforeShow: async (options): Promise<void> => {
await ServerConfiguration.configPromise;
Skeleton.append("pageLeaderboards", "main");
// await appendLanguageButtons(); //todo figure out this race condition
await updateValidDailyLeaderboards();
await appendModeAndLanguageButtons();
readGetParameters(options.urlParams);
checkIfLeaderboardIsValid();
startTimer();
updateTypeButtons();
updateTitle();
updateSecondaryButtons();
updateContent();
updateSideButtons();
updateGetParameters();
void requestData(false);
},
afterShow: async (): Promise<void> => {
updateSecondaryButtons();
// updateSideButtons();
},
});

View file

@ -2,6 +2,7 @@ import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyClientError,
responseWithData,
responseWithNullableData,
} from "./schemas/api";
@ -145,6 +146,7 @@ export const leaderboardsContract = c.router(
query: GetLeaderboardQuerySchema.strict(),
responses: {
200: GetLeaderboardResponseSchema,
404: MonkeyClientError,
},
metadata: meta({
authenticationOptions: { isPublic: true },