From 6593eebbbfade7a9507a6b48df8e56c39d8e6023 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:20:38 -0400 Subject: [PATCH 1/9] feat(leaderboards): add value search --- apps/api/src/dtos/guild-leaderboard.dto.ts | 7 ++ apps/api/src/dtos/player-leaderboard.dto.ts | 8 +- .../guild-leaderboard.controller.ts | 5 +- .../src/leaderboards/leaderboard.service.ts | 39 +++++++++- .../player-leaderboard.controller.ts | 5 +- .../leaderboards/base.leaderboard-command.tsx | 74 ++++++++++++++++++- locales/en-US/default.json | 14 +++- locales/en-US/emojis.json | 3 +- packages/api-client/src/constants.ts | 3 +- packages/schemas/src/guild/index.ts | 8 +- .../field/get-leaderboard-metadata.ts | 1 + .../src/metadata/metadata.interface.ts | 5 ++ .../src/player/gamemodes/bedwars/index.ts | 1 + .../src/player/gamemodes/general/index.ts | 1 + .../schemas/src/player/gamemodes/pit/index.ts | 1 + .../src/player/gamemodes/skywars/index.ts | 1 + .../src/player/gamemodes/woolgames/index.ts | 1 + packages/schemas/src/util/index.ts | 1 + .../src/util/parse-leaderboard-value.ts | 73 ++++++++++++++++++ 19 files changed, 237 insertions(+), 14 deletions(-) create mode 100644 packages/schemas/src/util/parse-leaderboard-value.ts diff --git a/apps/api/src/dtos/guild-leaderboard.dto.ts b/apps/api/src/dtos/guild-leaderboard.dto.ts index 483977c3a..eb297cc36 100644 --- a/apps/api/src/dtos/guild-leaderboard.dto.ts +++ b/apps/api/src/dtos/guild-leaderboard.dto.ts @@ -11,6 +11,7 @@ import { Guild, getLeaderboardFields } from "@statsify/schemas"; import { IsEnum, IsInt, + IsNumber, IsOptional, IsString, MaxLength, @@ -39,6 +40,12 @@ export class GuildLeaderboardDto { @ApiProperty({ minimum: 1, type: () => Number, required: false }) public position?: number; + @Transform((params) => +params.value) + @IsOptional() + @IsNumber() + @ApiProperty({ type: () => Number, required: false }) + public value?: number; + @IsOptional() @IsString() @MinLength(3) diff --git a/apps/api/src/dtos/player-leaderboard.dto.ts b/apps/api/src/dtos/player-leaderboard.dto.ts index 0e39b70ed..f874ab3d0 100644 --- a/apps/api/src/dtos/player-leaderboard.dto.ts +++ b/apps/api/src/dtos/player-leaderboard.dto.ts @@ -7,7 +7,7 @@ */ import { ApiProperty, PartialType } from "@nestjs/swagger"; -import { IsEnum, IsInt, IsOptional, Min } from "class-validator"; +import { IsEnum, IsInt, IsNumber, IsOptional, Min } from "class-validator"; import { Player, getLeaderboardFields } from "@statsify/schemas"; import { PlayerDto } from "./player.dto.js"; import { Transform } from "class-transformer"; @@ -31,4 +31,10 @@ export class PlayerLeaderboardDto extends PartialType(PlayerDto) { @Min(1) @ApiProperty({ minimum: 1, type: () => Number, required: false }) public position?: number; + + @Transform((params) => +params.value) + @IsOptional() + @IsNumber() + @ApiProperty({ type: () => Number, required: false }) + public value?: number; } diff --git a/apps/api/src/guild/leaderboards/guild-leaderboard.controller.ts b/apps/api/src/guild/leaderboards/guild-leaderboard.controller.ts index 70eeb3474..56e58ed60 100644 --- a/apps/api/src/guild/leaderboards/guild-leaderboard.controller.ts +++ b/apps/api/src/guild/leaderboards/guild-leaderboard.controller.ts @@ -29,7 +29,7 @@ export class GuildLeaderboardController { @ApiBadRequestResponse({ type: ErrorResponse }) @Auth({ weight: 10 }) public async getGuildLeaderboard( - @Body() { field, page, guild, position }: GuildLeaderboardDto + @Body() { field, page, guild, position, value }: GuildLeaderboardDto ) { let input: number | string; let type: LeaderboardQuery; @@ -40,6 +40,9 @@ export class GuildLeaderboardController { } else if (position) { input = position; type = LeaderboardQuery.POSITION; + } else if (value !== undefined) { + input = value; + type = LeaderboardQuery.VALUE; } else { input = page; type = LeaderboardQuery.PAGE; diff --git a/apps/api/src/leaderboards/leaderboard.service.ts b/apps/api/src/leaderboards/leaderboard.service.ts index 58a15af98..528628488 100644 --- a/apps/api/src/leaderboards/leaderboard.service.ts +++ b/apps/api/src/leaderboards/leaderboard.service.ts @@ -10,7 +10,7 @@ import * as Sentry from "@sentry/node"; import { Constructor, Flatten } from "@statsify/util"; import { DateTime } from "luxon"; import { InjectRedis } from "#redis"; -import { Injectable, InternalServerErrorException } from "@nestjs/common"; +import { Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common"; import { type LeaderboardEnabledMetadata, getLeaderboardField, getLeaderboardFields } from "@statsify/schemas"; import { LeaderboardQuery } from "@statsify/api-client"; import { Redis } from "ioredis"; @@ -106,14 +106,26 @@ export abstract class LeaderboardService { case LeaderboardQuery.INPUT: { const ranking = await this.searchLeaderboardInput(input as string, field); highlight = ranking - 1; - top = highlight - (highlight % 10); + top = highlight - (highlight % PAGE_SIZE); bottom = top + PAGE_SIZE; break; } case LeaderboardQuery.POSITION: { const position = (input as number) - 1; highlight = position; - top = position - (position % 10); + top = position - (position % PAGE_SIZE); + bottom = top + PAGE_SIZE; + break; + } + case LeaderboardQuery.VALUE: { + const position = await this.searchLeaderboardValue( + constructor, + field, + input as number, + sort + ); + highlight = position; + top = position - (position % PAGE_SIZE); bottom = top + PAGE_SIZE; break; } @@ -302,6 +314,27 @@ export abstract class LeaderboardService { return response; } + private async searchLeaderboardValue( + constructor: Constructor, + field: string, + value: number, + sort = "DESC" + ) { + const key = `${constructor.name.toLowerCase()}.${field}`; + + const [position, size] = await Promise.all([ + sort === "ASC" ? + this.redis.zcount(key, "-inf", `(${value}`) : + this.redis.zcount(key, `(${value}`, "+inf"), + this.redis.zcard(key), + ]); + + // Leaderboards are trimmed to their limit, so a value past the end has no known position + if (position >= size) throw new NotFoundException("value"); + + return position; + } + private getLeaderboardExpiryTime(leaderboard: LeaderboardEnabledMetadata): number { if (!leaderboard.resetEvery) throw new Error("To get a leaderboard expiry time, `resetEvery` must be specified"); diff --git a/apps/api/src/player/leaderboards/player-leaderboard.controller.ts b/apps/api/src/player/leaderboards/player-leaderboard.controller.ts index 567d5f4cf..c8b142e6a 100644 --- a/apps/api/src/player/leaderboards/player-leaderboard.controller.ts +++ b/apps/api/src/player/leaderboards/player-leaderboard.controller.ts @@ -37,7 +37,7 @@ export class PlayerLeaderboardsController { @ApiBadRequestResponse({ type: ErrorResponse }) @Auth({ weight: 3 }) public getPlayerLeaderboard( - @Body() { field, page, player, position }: PlayerLeaderboardDto + @Body() { field, page, player, position, value }: PlayerLeaderboardDto ) { let input: number | string; let type: LeaderboardQuery; @@ -48,6 +48,9 @@ export class PlayerLeaderboardsController { } else if (position) { input = position; type = LeaderboardQuery.POSITION; + } else if (value !== undefined) { + input = value; + type = LeaderboardQuery.VALUE; } else { input = page; type = LeaderboardQuery.PAGE; diff --git a/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx b/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx index a2d2e1868..6b9c58acb 100644 --- a/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx +++ b/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx @@ -27,7 +27,7 @@ import { LeaderboardQuery, PostLeaderboardResponse, } from "@statsify/api-client"; -import { User } from "@statsify/schemas"; +import { Guild, Player, User, parseLeaderboardValue } from "@statsify/schemas"; import { getLogo } from "@statsify/assets"; import { getTheme } from "#themes"; import { render } from "@statsify/rendering"; @@ -96,6 +96,10 @@ export class BaseLeaderboardCommand { .emoji(t("emojis:search.position")) .style(ButtonStyle.Primary); + const searchValue = new ButtonBuilder() + .emoji(t("emojis:search.value")) + .style(ButtonStyle.Primary); + let currentPage = 0; const changePage = @@ -118,7 +122,13 @@ export class BaseLeaderboardCommand { if (interaction.getUserId() === userId && !message.ephemeral) { up.disable(page === 0); currentPage = page || currentPage; - const row = new ActionRowBuilder([up, down, searchDocument, searchPosition]); + const row = new ActionRowBuilder([ + up, + down, + searchDocument, + searchPosition, + searchValue, + ]); context.reply({ ...message, @@ -174,6 +184,19 @@ export class BaseLeaderboardCommand { ) ); + const valueModal = new ModalBuilder() + .title((t) => t("leaderboard.modal.title")) + .component( + new ActionRowBuilder().component( + new TextInputBuilder() + .label((t) => t("leaderboard.valueInput.label")) + .placeholder((t) => t("leaderboard.valueInput.placeholder")) + .minLength(1) + .maxLength(16) + .required(true) + ) + ); + listener.addHook(searchDocument.getCustomId(), () => ({ type: InteractionResponseType.Modal, data: documentModal.build(t), @@ -184,6 +207,11 @@ export class BaseLeaderboardCommand { data: positionModal.build(t), })); + listener.addHook(searchValue.getCustomId(), () => ({ + type: InteractionResponseType.Modal, + data: valueModal.build(t), + })); + listener.addHook(documentModal.getCustomId(), async (interaction) => { const data = interaction.getData(); const documentInput = data.components[0].components[0].value; @@ -214,7 +242,39 @@ export class BaseLeaderboardCommand { ); }); - const row = new ActionRowBuilder([up, down, searchDocument, searchPosition]); + listener.addHook(valueModal.getCustomId(), async (interaction) => { + const data = interaction.getData(); + const valueInput = data.components[0].components[0].value; + + const value = parseLeaderboardValue( + type === "player" ? Player : Guild, + field, + valueInput + ); + + if (user?.locale) interaction.setLocale(user.locale); + + if (Number.isNaN(value)) { + const error = new ErrorMessage("errors.leaderboardInvalidValue"); + + return interaction.sendFollowup({ + ...error, + ephemeral: true, + }); + } + + changePage(() => ({ input: value, type: LeaderboardQuery.VALUE }))( + interaction + ); + }); + + const row = new ActionRowBuilder([ + up, + down, + searchDocument, + searchPosition, + searchValue, + ]); const [message, page] = await this.getLeaderboardMessage( user, @@ -234,8 +294,10 @@ export class BaseLeaderboardCommand { listener.removeHook(down.getCustomId()); listener.removeHook(searchDocument.getCustomId()); listener.removeHook(searchPosition.getCustomId()); + listener.removeHook(searchValue.getCustomId()); listener.removeHook(documentModal.getCustomId()); listener.removeHook(positionModal.getCustomId()); + listener.removeHook(valueModal.getCustomId()); context.reply({ embeds: [], components: [] }); cache.clear(); @@ -289,7 +351,11 @@ export class BaseLeaderboardCommand { if (!leaderboard) { const message = { - ...new ErrorMessage(`errors.${type}LeaderboardNotFound`), + ...new ErrorMessage( + params.type === LeaderboardQuery.VALUE ? + "errors.leaderboardValueNotFound" : + `errors.${type}LeaderboardNotFound` + ), ephemeral: true, }; diff --git a/locales/en-US/default.json b/locales/en-US/default.json index ea0eab24b..385c671e3 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -312,7 +312,7 @@ "title": "Sessions" }, "leaderboards": { - "description": "Statsify offers thousands of leaderboards through the `/leaderboard` command. Run `/leaderboard`, then pick the gamemode, and pick a leaderboard to view. You can hit the search button to search for a specific player or position. You can also hit the up and down buttons to view more pages.", + "description": "Statsify offers thousands of leaderboards through the `/leaderboard` command. Run `/leaderboard`, then pick the gamemode, and pick a leaderboard to view. You can hit the search buttons to search for a specific player, position or value. You can also hit the up and down buttons to view more pages.", "title": "Leaderboards" }, "minecraft": { @@ -428,10 +428,18 @@ "description": "The leaderboard position you entered is invalid. Enter a number greater than 1.", "title": "Invalid Position" }, + "leaderboardInvalidValue": { + "description": "The leaderboard value you entered is invalid. Enter a number like `10k`, a level like `500` or a time like `1:23.45`.", + "title": "Invalid Value" + }, "leaderboardNotFound": { "description": "We can't find the `leaderboard` you are looking for!", "title": "Leaderboard Not Found" }, + "leaderboardValueNotFound": { + "description": "The leaderboard doesn't go down as far as the value you entered!", + "title": "No Leaderboard Data At This Value" + }, "missingMentionVerification": { "description": "<@{{tag}}> is not **verified**. To mention them, they must run `/verify` to verify their account.", "title": "Missing Verification" @@ -539,6 +547,10 @@ "positionInput": { "label": "Search by a Position", "placeholder": "Enter a position, eg: 5000" + }, + "valueInput": { + "label": "Search by a Value", + "placeholder": "Enter a value, eg: 10k, 500 or 1:23.45" } }, "minecraft": { diff --git a/locales/en-US/emojis.json b/locales/en-US/emojis.json index 66a9047b0..d865ecbcc 100644 --- a/locales/en-US/emojis.json +++ b/locales/en-US/emojis.json @@ -4,7 +4,8 @@ "search": { "player": "<:searchplayer:995761551913005166>", "guild": "<:searchguild:996434315938381844>", - "position": "<:searchposition:995761551048966154>" + "position": "<:searchposition:995761551048966154>", + "value": "<:searchvalue:1509622552828317696>" }, "check": "<:check:995761550310772777>", "cross": "<:cross:995761549316726884>", diff --git a/packages/api-client/src/constants.ts b/packages/api-client/src/constants.ts index 52310234b..a32dc88da 100644 --- a/packages/api-client/src/constants.ts +++ b/packages/api-client/src/constants.ts @@ -37,5 +37,6 @@ export enum CacheLevel { export enum LeaderboardQuery { PAGE = "page", INPUT = "input", - POSITION = "position" + POSITION = "position", + VALUE = "value" } diff --git a/packages/schemas/src/guild/index.ts b/packages/schemas/src/guild/index.ts index 33c427164..e5d07a8da 100644 --- a/packages/schemas/src/guild/index.ts +++ b/packages/schemas/src/guild/index.ts @@ -39,7 +39,13 @@ export class Guild { public createdAt: number; @Field({ - leaderboard: { fieldName: "Level", hidden: true, additionalFields: ["level"], limit }, + leaderboard: { + fieldName: "Level", + hidden: true, + additionalFields: ["level"], + limit, + searchBy: (exp: number) => getLevel(exp).level, + }, }) public exp: number; diff --git a/packages/schemas/src/metadata/field/get-leaderboard-metadata.ts b/packages/schemas/src/metadata/field/get-leaderboard-metadata.ts index 4f10a81b1..5ec2ea5eb 100644 --- a/packages/schemas/src/metadata/field/get-leaderboard-metadata.ts +++ b/packages/schemas/src/metadata/field/get-leaderboard-metadata.ts @@ -105,6 +105,7 @@ export const getLeaderboardMetadata = ( limit: leaderboardOptions?.limit ?? getDefaultLeaderboardLimit(propertyKey), resetEvery: leaderboardOptions?.resetEvery, + searchBy: leaderboardOptions?.searchBy, }; } diff --git a/packages/schemas/src/metadata/metadata.interface.ts b/packages/schemas/src/metadata/metadata.interface.ts index cff6337f3..c802ae9a6 100644 --- a/packages/schemas/src/metadata/metadata.interface.ts +++ b/packages/schemas/src/metadata/metadata.interface.ts @@ -109,6 +109,11 @@ export interface LeaderboardEnabledMetadata extends BaseLeaderboardMetadata { * How many members to show in the leaderboard. */ limit: number; + + /** + * Turns a score into what users search the leaderboard by, like the level on an exp leaderboard. It must never go down as the score goes up. + */ + searchBy?: (score: number) => number; } export type LeaderboardMetadata = diff --git a/packages/schemas/src/player/gamemodes/bedwars/index.ts b/packages/schemas/src/player/gamemodes/bedwars/index.ts index b23c58ca2..714a0ff17 100644 --- a/packages/schemas/src/player/gamemodes/bedwars/index.ts +++ b/packages/schemas/src/player/gamemodes/bedwars/index.ts @@ -86,6 +86,7 @@ export class BedWars { hidden: true, formatter: (exp: number) => getIntendedFormattedLevel(Math.floor(getLevel(exp))), + searchBy: getLevel, additionalFields: [ "this.overall.wins", "this.overall.finalKills", diff --git a/packages/schemas/src/player/gamemodes/general/index.ts b/packages/schemas/src/player/gamemodes/general/index.ts index f3e6dd85e..c710222a3 100644 --- a/packages/schemas/src/player/gamemodes/general/index.ts +++ b/packages/schemas/src/player/gamemodes/general/index.ts @@ -32,6 +32,7 @@ export class General { hidden: true, limit: Number.POSITIVE_INFINITY, formatter: getNetworkLevel, + searchBy: getNetworkLevel, additionalFields: ["this.networkLevel"], }, historical: { diff --git a/packages/schemas/src/player/gamemodes/pit/index.ts b/packages/schemas/src/player/gamemodes/pit/index.ts index b647ec65c..01e36ebf6 100644 --- a/packages/schemas/src/player/gamemodes/pit/index.ts +++ b/packages/schemas/src/player/gamemodes/pit/index.ts @@ -39,6 +39,7 @@ export class Pit { const level = getLevel(prestige, exp); return getLevelFormatted(level, prestige); }, + searchBy: getPrestige, }, historical: { hidden: false, diff --git a/packages/schemas/src/player/gamemodes/skywars/index.ts b/packages/schemas/src/player/gamemodes/skywars/index.ts index 1de1e1186..8575052d5 100644 --- a/packages/schemas/src/player/gamemodes/skywars/index.ts +++ b/packages/schemas/src/player/gamemodes/skywars/index.ts @@ -44,6 +44,7 @@ export class SkyWars { fieldName: "Level", hidden: true, formatter: (exp: number) => getLevel(exp), + searchBy: getLevel, additionalFields: ["this.overall.wins", "this.overall.kills", "this.overall.kdr"], }, historical: { diff --git a/packages/schemas/src/player/gamemodes/woolgames/index.ts b/packages/schemas/src/player/gamemodes/woolgames/index.ts index 18d30b091..57efb68bc 100644 --- a/packages/schemas/src/player/gamemodes/woolgames/index.ts +++ b/packages/schemas/src/player/gamemodes/woolgames/index.ts @@ -50,6 +50,7 @@ export class WoolGames { fieldName: "Level", hidden: true, formatter: (exp: number) => getFormattedLevel(Math.floor(getLevel(exp))), + searchBy: getLevel, additionalFields: ["this.wins", "this.playtime"], }, historical: { diff --git a/packages/schemas/src/util/index.ts b/packages/schemas/src/util/index.ts index 87b5d83fc..b7b584104 100644 --- a/packages/schemas/src/util/index.ts +++ b/packages/schemas/src/util/index.ts @@ -7,4 +7,5 @@ */ export * from "./leaderboard-scanner.js"; +export * from "./parse-leaderboard-value.js"; export * from "./create-historical-player.js"; diff --git a/packages/schemas/src/util/parse-leaderboard-value.ts b/packages/schemas/src/util/parse-leaderboard-value.ts new file mode 100644 index 000000000..7889d37f8 --- /dev/null +++ b/packages/schemas/src/util/parse-leaderboard-value.ts @@ -0,0 +1,73 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { type Constructor, formatRaceTime, formatTime } from "@statsify/util"; +import { getLeaderboardField } from "./leaderboard-scanner.js"; + +const SUFFIXES: Record = { k: 1e3, m: 1e6, b: 1e9 }; + +// Binary search for the lowest score that reaches the value, which needs searchBy to never go down +function getLowestScore(searchBy: (score: number) => number, value: number) { + let low = 0; + let high = Number.MAX_SAFE_INTEGER; + + if (searchBy(high) < value) return Number.NaN; + + while (low < high) { + const mid = low + Math.floor((high - low) / 2); + + if (searchBy(mid) < value) low = mid + 1; + else high = mid; + } + + return low; +} + +/** + * Turns a value searched on a leaderboard, like `10k`, `500✫` or `1:23.45`, into the score the leaderboard is sorted by + * @returns NaN if the value can't be read + */ +export function parseLeaderboardValue( + constructor: Constructor, + key: string, + input: string, +) { + const { formatter, searchBy } = getLeaderboardField(constructor, key); + const search = input.replaceAll(",", "").trim().toLowerCase(); + + // Times are searched in seconds, like 1:23.45 or 83.45, but stored in milliseconds + if (formatter === formatTime || formatter === formatRaceTime) { + const seconds = search + .split(":") + .reduce((total, part) => total * 60 + Number(part), 0); + + return Math.round(seconds * 1000); + } + + const match = /^(\d*\.?\d+)([kmb])?/.exec(search); + if (!match) return Number.NaN; + + const [, number, suffix] = match; + const value = Number(number) * (suffix ? SUFFIXES[suffix] : 1); + + return searchBy ? getLowestScore(searchBy, value) : value; +} + +if (import.meta.vitest) { + const { it, expect } = import.meta.vitest; + + const { Player } = await import("#player"); + + it("parses numbers, times and levels", () => { + expect(parseLeaderboardValue(Player, "stats.bedwars.overall.wins", "1.5k")).toBe(1500); + expect(parseLeaderboardValue(Player, "stats.bedwars.overall.wins", "wins")).toBeNaN(); + expect(parseLeaderboardValue(Player, "stats.parkour.BEDWARS", "1:23.45")).toBe(83_450); + expect(parseLeaderboardValue(Player, "stats.bedwars.exp", "500✫")).toBe(2_435_000); + expect(parseLeaderboardValue(Player, "stats.skywars.exp", "20000")).toBeNaN(); + }); +} From 0c56e93904ea18f5e11ffe40929e020ef00f82fc Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:43:31 -0400 Subject: [PATCH 2/9] refactor(discord-bot): declare the leaderboard button row once --- .../leaderboards/base.leaderboard-command.tsx | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx b/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx index 6b9c58acb..b82217b1e 100644 --- a/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx +++ b/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx @@ -100,6 +100,14 @@ export class BaseLeaderboardCommand { .emoji(t("emojis:search.value")) .style(ButtonStyle.Primary); + const row = new ActionRowBuilder([ + up, + down, + searchDocument, + searchPosition, + searchValue, + ]); + let currentPage = 0; const changePage = @@ -122,13 +130,6 @@ export class BaseLeaderboardCommand { if (interaction.getUserId() === userId && !message.ephemeral) { up.disable(page === 0); currentPage = page || currentPage; - const row = new ActionRowBuilder([ - up, - down, - searchDocument, - searchPosition, - searchValue, - ]); context.reply({ ...message, @@ -268,14 +269,6 @@ export class BaseLeaderboardCommand { ); }); - const row = new ActionRowBuilder([ - up, - down, - searchDocument, - searchPosition, - searchValue, - ]); - const [message, page] = await this.getLeaderboardMessage( user, cache, From b0321d1ad34aab8225bdceb085192a112f3fe7dc Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:23:23 -0400 Subject: [PATCH 3/9] feat(leaderboards): search times by unit, like 5d 3h --- locales/en-US/default.json | 4 +-- .../src/util/parse-leaderboard-value.ts | 25 ++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/locales/en-US/default.json b/locales/en-US/default.json index 385c671e3..e8d04ddaa 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -429,7 +429,7 @@ "title": "Invalid Position" }, "leaderboardInvalidValue": { - "description": "The leaderboard value you entered is invalid. Enter a number like `10k`, a level like `500` or a time like `1:23.45`.", + "description": "The leaderboard value you entered is invalid. Enter a number like `10k`, a level like `500` or a time like `1:23.45` or `5d 3h`.", "title": "Invalid Value" }, "leaderboardNotFound": { @@ -550,7 +550,7 @@ }, "valueInput": { "label": "Search by a Value", - "placeholder": "Enter a value, eg: 10k, 500 or 1:23.45" + "placeholder": "Enter a value, eg: 10k, 500, 1:23.45 or 5d 3h" } }, "minecraft": { diff --git a/packages/schemas/src/util/parse-leaderboard-value.ts b/packages/schemas/src/util/parse-leaderboard-value.ts index 7889d37f8..959993b0c 100644 --- a/packages/schemas/src/util/parse-leaderboard-value.ts +++ b/packages/schemas/src/util/parse-leaderboard-value.ts @@ -11,6 +11,17 @@ import { getLeaderboardField } from "./leaderboard-scanner.js"; const SUFFIXES: Record = { k: 1e3, m: 1e6, b: 1e9 }; +// formatTime's units in milliseconds, so a time can be searched the way the leaderboard shows it +const TIME_UNITS: Record = { + y: 31_104_000_000, + mo: 2_592_000_000, + d: 86_400_000, + h: 3_600_000, + m: 60_000, + s: 1000, + ms: 1, +}; + // Binary search for the lowest score that reaches the value, which needs searchBy to never go down function getLowestScore(searchBy: (score: number) => number, value: number) { let low = 0; @@ -40,8 +51,19 @@ export function parseLeaderboardValue( const { formatter, searchBy } = getLeaderboardField(constructor, key); const search = input.replaceAll(",", "").trim().toLowerCase(); - // Times are searched in seconds, like 1:23.45 or 83.45, but stored in milliseconds + // Times are searched in seconds, like 1:23.45 or 83.45, or in units, like 5d 3h, but stored in milliseconds if (formatter === formatTime || formatter === formatRaceTime) { + const units = [...search.matchAll(/(\d*\.?\d+)\s*(mo|ms|[ydhms])/g)]; + + if (units.length > 0) { + const time = units.reduce( + (total, [, number, unit]) => total + Number(number) * TIME_UNITS[unit], + 0 + ); + + return Math.round(time); + } + const seconds = search .split(":") .reduce((total, part) => total * 60 + Number(part), 0); @@ -67,6 +89,7 @@ if (import.meta.vitest) { expect(parseLeaderboardValue(Player, "stats.bedwars.overall.wins", "1.5k")).toBe(1500); expect(parseLeaderboardValue(Player, "stats.bedwars.overall.wins", "wins")).toBeNaN(); expect(parseLeaderboardValue(Player, "stats.parkour.BEDWARS", "1:23.45")).toBe(83_450); + expect(parseLeaderboardValue(Player, "stats.pit.playtime", "5d, 3h")).toBe(442_800_000); expect(parseLeaderboardValue(Player, "stats.bedwars.exp", "500✫")).toBe(2_435_000); expect(parseLeaderboardValue(Player, "stats.skywars.exp", "20000")).toBeNaN(); }); From db5d054848164bac836b3a70150aa1f8ea28e38b Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:19:32 -0500 Subject: [PATCH 4/9] refactor(schemas): read leaderboard search values with a parser next to the formatter --- .../field/get-leaderboard-metadata.ts | 1 + .../src/metadata/metadata.interface.ts | 5 +++ .../src/player/gamemodes/arcade/mode.ts | 27 +++++++------- .../src/player/gamemodes/blitzsg/kit.ts | 4 +-- .../src/player/gamemodes/duels/mode.ts | 4 +-- .../src/player/gamemodes/megawalls/kit.ts | 6 ++-- .../player/gamemodes/murdermystery/mode.ts | 6 ++-- .../src/player/gamemodes/parkour/index.ts | 4 +-- .../schemas/src/player/gamemodes/pit/index.ts | 4 +-- .../src/player/gamemodes/skywars/mode.ts | 6 ++-- .../src/player/gamemodes/tntgames/mode.ts | 8 ++--- .../gamemodes/woolgames/capture-the-wool.ts | 6 ++-- .../src/player/gamemodes/woolgames/index.ts | 4 +-- .../src/util/parse-leaderboard-value.ts | 35 ++----------------- packages/util/src/util.ts | 21 +++++++++++ 15 files changed, 71 insertions(+), 70 deletions(-) diff --git a/packages/schemas/src/metadata/field/get-leaderboard-metadata.ts b/packages/schemas/src/metadata/field/get-leaderboard-metadata.ts index 5ec2ea5eb..258b09cc6 100644 --- a/packages/schemas/src/metadata/field/get-leaderboard-metadata.ts +++ b/packages/schemas/src/metadata/field/get-leaderboard-metadata.ts @@ -105,6 +105,7 @@ export const getLeaderboardMetadata = ( limit: leaderboardOptions?.limit ?? getDefaultLeaderboardLimit(propertyKey), resetEvery: leaderboardOptions?.resetEvery, + parser: leaderboardOptions?.parser, searchBy: leaderboardOptions?.searchBy, }; } diff --git a/packages/schemas/src/metadata/metadata.interface.ts b/packages/schemas/src/metadata/metadata.interface.ts index c802ae9a6..749fee29e 100644 --- a/packages/schemas/src/metadata/metadata.interface.ts +++ b/packages/schemas/src/metadata/metadata.interface.ts @@ -110,6 +110,11 @@ export interface LeaderboardEnabledMetadata extends BaseLeaderboardMetadata { */ limit: number; + /** + * Reads a value searched on the leaderboard back into a score, the reverse of the formatter. For example if the field is a time, it will read `1:23.45` as milliseconds + */ + parser?: (input: string) => number; + /** * Turns a score into what users search the leaderboard by, like the level on an exp leaderboard. It must never go down as the score goes up. */ diff --git a/packages/schemas/src/player/gamemodes/arcade/mode.ts b/packages/schemas/src/player/gamemodes/arcade/mode.ts index e2dd5a1a2..68f951dd7 100644 --- a/packages/schemas/src/player/gamemodes/arcade/mode.ts +++ b/packages/schemas/src/player/gamemodes/arcade/mode.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatRaceTime, formatTime } from "@statsify/util"; +import { type APIData, formatRaceTime, formatTime, parseTime } from "@statsify/util"; import { EasterSimulator, GrinchSimulator, @@ -415,7 +415,7 @@ export class Disasters { @Field() public wlr: number; - @Field({ leaderboard: { formatter: formatTime } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime } }) public playtime: number; @Field({ leaderboard: { name: "" } }) @@ -460,7 +460,7 @@ export class DropperMap { @Field() public completions: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public bestTime: number; public constructor(map: APIData = {}) { @@ -733,7 +733,7 @@ export class Dropper { @Field() public flawlessGames: number; - @Field({ leaderboard: { formatter: formatTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime, sort: "ASC" } }) public bestTime: number; @Field({ leaderboard: { name: "Maps:" } }) @@ -1147,32 +1147,32 @@ export class PartyGames { public workshopWins: number; // The goal of anvil spleef is to live the longest not the shortest so we sort in descending order - @Field({ leaderboard: { formatter: formatRaceTime } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime } }) public anvilSpleefBestTime: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public labEscapeBestTime: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public jigsawRushBestTime: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public theFloorIsLavaBestTime: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public chickenRingsBestTime: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public jungleJumpBestTime: number; // The goal of bombardment is to live the longest not the shortest so we sort in descending order - @Field({ leaderboard: { formatter: formatRaceTime } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime } }) public bombardmentBestTime: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public minecartRacingBestTime: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public spiderMazeBestTime: number; @Field() @@ -1371,6 +1371,7 @@ export class ZombiesMapDifficulty { leaderboard: { sort: "ASC", formatter: formatTime, + parser: parseTime, additionalFields: ["this.wins"], }, historical: { enabled: false }, diff --git a/packages/schemas/src/player/gamemodes/blitzsg/kit.ts b/packages/schemas/src/player/gamemodes/blitzsg/kit.ts index f6eb86f38..68d9d36d7 100644 --- a/packages/schemas/src/player/gamemodes/blitzsg/kit.ts +++ b/packages/schemas/src/player/gamemodes/blitzsg/kit.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, findScoreIndex, formatTime } from "@statsify/util"; +import { type APIData, findScoreIndex, formatTime, parseTime } from "@statsify/util"; import { Field } from "#metadata"; import { add, ratio, sub } from "@statsify/math"; @@ -45,7 +45,7 @@ export class BlitzSGKit { @Field({ store: { required: false }, - leaderboard: { formatter: formatTime }, + leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false }, }) public playtime: number; diff --git a/packages/schemas/src/player/gamemodes/duels/mode.ts b/packages/schemas/src/player/gamemodes/duels/mode.ts index 827f27931..433de6766 100644 --- a/packages/schemas/src/player/gamemodes/duels/mode.ts +++ b/packages/schemas/src/player/gamemodes/duels/mode.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { Field } from "#metadata"; import { Progression } from "#progression"; import { TitleRequirement, getTitleAndProgression } from "./util.js"; @@ -648,7 +648,7 @@ export class ParkourDuels extends SingleDuelsGameMode { @Field() public checkpoints: number; - @Field({ leaderboard: { formatter: formatTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime, sort: "ASC" } }) public bestTime: number; public constructor(data: APIData) { diff --git a/packages/schemas/src/player/gamemodes/megawalls/kit.ts b/packages/schemas/src/player/gamemodes/megawalls/kit.ts index bfd11d607..4008ba5d7 100644 --- a/packages/schemas/src/player/gamemodes/megawalls/kit.ts +++ b/packages/schemas/src/player/gamemodes/megawalls/kit.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { Field } from "#metadata"; import { add, ratio } from "@statsify/math"; @@ -47,7 +47,7 @@ export class MegaWallsKit { public assists: number; @Field({ - leaderboard: { limit, formatter: formatTime }, + leaderboard: { limit, formatter: formatTime, parser: parseTime }, historical: { enabled: false }, }) public playtime: number; @@ -125,7 +125,7 @@ export class MegaWallsOverall extends MegaWallsKit { @Field() public declare assists: number; - @Field({ leaderboard: { formatter: formatTime } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime } }) public declare playtime: number; public constructor(data: APIData) { diff --git a/packages/schemas/src/player/gamemodes/murdermystery/mode.ts b/packages/schemas/src/player/gamemodes/murdermystery/mode.ts index 3fe4ad413..cd8c65676 100644 --- a/packages/schemas/src/player/gamemodes/murdermystery/mode.ts +++ b/packages/schemas/src/player/gamemodes/murdermystery/mode.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime, prettify } from "@statsify/util"; +import { type APIData, formatTime, prettify, parseTime } from "@statsify/util"; import { Field } from "#metadata"; import { Progression } from "#progression"; import { ratio } from "@statsify/math"; @@ -98,13 +98,13 @@ export class OverallMurderMysteryMode extends StandardMurderMysteryMode { export class ClassicMurderMysteryMode extends StandardMurderMysteryMode { @Field({ - leaderboard: { sort: "ASC", formatter: formatTime }, + leaderboard: { sort: "ASC", formatter: formatTime, parser: parseTime }, historical: { enabled: false }, }) public fastestDetectiveWin: number; @Field({ - leaderboard: { sort: "ASC", formatter: formatTime }, + leaderboard: { sort: "ASC", formatter: formatTime, parser: parseTime }, historical: { enabled: false }, }) public fastestMurdererWin: number; diff --git a/packages/schemas/src/player/gamemodes/parkour/index.ts b/packages/schemas/src/player/gamemodes/parkour/index.ts index 8936c32f9..a65b6e55f 100644 --- a/packages/schemas/src/player/gamemodes/parkour/index.ts +++ b/packages/schemas/src/player/gamemodes/parkour/index.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { type ExtractGameModes, FormattedGame, GameModes } from "#game"; import { Field } from "#metadata"; @@ -14,7 +14,7 @@ export const PARKOUR_MODES = new GameModes([{ api: "overall" }] as const); export type ParkourModes = ExtractGameModes; -const fieldOptions = { sort: "ASC", formatter: formatTime, fieldName: "Time" }; +const fieldOptions = { sort: "ASC", formatter: formatTime, parser: parseTime, fieldName: "Time" }; const historical = { enabled: false }; export class Parkour { diff --git a/packages/schemas/src/player/gamemodes/pit/index.ts b/packages/schemas/src/player/gamemodes/pit/index.ts index 01e36ebf6..9119bf815 100644 --- a/packages/schemas/src/player/gamemodes/pit/index.ts +++ b/packages/schemas/src/player/gamemodes/pit/index.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { type ExtractGameModes, GameModes } from "#game"; import { Field } from "#metadata"; import { Progression } from "#progression"; @@ -124,7 +124,7 @@ export class Pit { }) public totalMysticsEnchanted: number; - @Field({ leaderboard: { formatter: formatTime }, historical: { enabled: false } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false } }) public playtime: number; @Field({ historical: { enabled: false } }) diff --git a/packages/schemas/src/player/gamemodes/skywars/mode.ts b/packages/schemas/src/player/gamemodes/skywars/mode.ts index 6ff966260..6142f74e7 100644 --- a/packages/schemas/src/player/gamemodes/skywars/mode.ts +++ b/packages/schemas/src/player/gamemodes/skywars/mode.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { Field } from "#metadata"; import { add, ratio, sub } from "@statsify/math"; @@ -35,7 +35,7 @@ export class SkyWarsMini implements SkyWarsMode { @Field() public assists: number; - @Field({ leaderboard: { formatter: formatTime }, historical: { enabled: false } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false } }) public playtime: number; // Kit gets applied in the main class @@ -89,7 +89,7 @@ export class SkyWarsMode { @Field() public assists: number; - @Field({ leaderboard: { formatter: formatTime }, historical: { enabled: false } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false } }) public playtime: number; // Kit gets applied in the main class diff --git a/packages/schemas/src/player/gamemodes/tntgames/mode.ts b/packages/schemas/src/player/gamemodes/tntgames/mode.ts index abb824ba2..876c799da 100644 --- a/packages/schemas/src/player/gamemodes/tntgames/mode.ts +++ b/packages/schemas/src/player/gamemodes/tntgames/mode.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { Field } from "#metadata"; import { type GamePrefix, createPrefixProgression, cycleColors, defaultPrefix, getFormattedPrefix } from "#prefixes"; import { Progression } from "#progression"; @@ -95,7 +95,7 @@ export class PVPRun { @Field() public kdr: number; - @Field({ leaderboard: { formatter: formatTime }, historical: { enabled: false } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false } }) public record: number; @Field() @@ -149,7 +149,7 @@ export class TNTRun { @Field() public wlr: number; - @Field({ leaderboard: { formatter: formatTime }, historical: { enabled: false } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false } }) public record: number; @Field() @@ -319,7 +319,7 @@ export class Wizards { @Field() public points: number; - @Field({ leaderboard: { formatter: formatTime } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime } }) public airTime: number; @Field() diff --git a/packages/schemas/src/player/gamemodes/woolgames/capture-the-wool.ts b/packages/schemas/src/player/gamemodes/woolgames/capture-the-wool.ts index 477c45511..13ac99c29 100644 --- a/packages/schemas/src/player/gamemodes/woolgames/capture-the-wool.ts +++ b/packages/schemas/src/player/gamemodes/woolgames/capture-the-wool.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { Field } from "#metadata"; import { ratio } from "@statsify/math"; @@ -45,6 +45,7 @@ export class CaptureTheWool { leaderboard: { sort: "ASC", formatter: formatTime, + parser: parseTime, additionalFields: ["this.wins"], }, historical: { enabled: false }, @@ -55,6 +56,7 @@ export class CaptureTheWool { leaderboard: { sort: "ASC", formatter: formatTime, + parser: parseTime, additionalFields: ["this.woolCaptured"], }, historical: { enabled: false }, @@ -62,7 +64,7 @@ export class CaptureTheWool { public fastestWoolCapture: number; @Field({ - leaderboard: { formatter: formatTime }, + leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false }, }) public longestGame: number; diff --git a/packages/schemas/src/player/gamemodes/woolgames/index.ts b/packages/schemas/src/player/gamemodes/woolgames/index.ts index 57efb68bc..973538bbb 100644 --- a/packages/schemas/src/player/gamemodes/woolgames/index.ts +++ b/packages/schemas/src/player/gamemodes/woolgames/index.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { CaptureTheWool } from "./capture-the-wool.js"; import { type ExtractGameModes, GameModes } from "#game"; import { Field } from "#metadata"; @@ -74,7 +74,7 @@ export class WoolGames { @Field() public nextLevelFormatted: string; - @Field({ leaderboard: { formatter: formatTime }, historical: { enabled: false } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false } }) public playtime: number; @Field() diff --git a/packages/schemas/src/util/parse-leaderboard-value.ts b/packages/schemas/src/util/parse-leaderboard-value.ts index 959993b0c..9f9090e01 100644 --- a/packages/schemas/src/util/parse-leaderboard-value.ts +++ b/packages/schemas/src/util/parse-leaderboard-value.ts @@ -6,22 +6,11 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type Constructor, formatRaceTime, formatTime } from "@statsify/util"; +import type { Constructor } from "@statsify/util"; import { getLeaderboardField } from "./leaderboard-scanner.js"; const SUFFIXES: Record = { k: 1e3, m: 1e6, b: 1e9 }; -// formatTime's units in milliseconds, so a time can be searched the way the leaderboard shows it -const TIME_UNITS: Record = { - y: 31_104_000_000, - mo: 2_592_000_000, - d: 86_400_000, - h: 3_600_000, - m: 60_000, - s: 1000, - ms: 1, -}; - // Binary search for the lowest score that reaches the value, which needs searchBy to never go down function getLowestScore(searchBy: (score: number) => number, value: number) { let low = 0; @@ -48,28 +37,10 @@ export function parseLeaderboardValue( key: string, input: string, ) { - const { formatter, searchBy } = getLeaderboardField(constructor, key); + const { parser, searchBy } = getLeaderboardField(constructor, key); const search = input.replaceAll(",", "").trim().toLowerCase(); - // Times are searched in seconds, like 1:23.45 or 83.45, or in units, like 5d 3h, but stored in milliseconds - if (formatter === formatTime || formatter === formatRaceTime) { - const units = [...search.matchAll(/(\d*\.?\d+)\s*(mo|ms|[ydhms])/g)]; - - if (units.length > 0) { - const time = units.reduce( - (total, [, number, unit]) => total + Number(number) * TIME_UNITS[unit], - 0 - ); - - return Math.round(time); - } - - const seconds = search - .split(":") - .reduce((total, part) => total * 60 + Number(part), 0); - - return Math.round(seconds * 1000); - } + if (parser) return parser(search); const match = /^(\d*\.?\d+)([kmb])?/.exec(search); if (!match) return Number.NaN; diff --git a/packages/util/src/util.ts b/packages/util/src/util.ts index ea19a0eb5..4c59dbe32 100644 --- a/packages/util/src/util.ts +++ b/packages/util/src/util.ts @@ -183,6 +183,27 @@ export const relativeTime = (time: number) => export const formatRaceTime = (timeMs: number) => `${(timeMs / 1000).toFixed(3)}s`; +const TIME_UNITS: Record = { + ms: 1, + s: MS, + m: MS * SECONDS, + h: MS * SECONDS * MINUTES, + d: MS * SECONDS * MINUTES * HOURS, + mo: MS * SECONDS * MINUTES * HOURS * MONTHS, + y: MS * SECONDS * MINUTES * HOURS * MONTHS * YEARS, +}; + +// Reads back formatTime and formatRaceTime, or seconds like 1:23.45, into milliseconds +export const parseTime = (input: string) => { + const units = [...input.matchAll(/(\d*\.?\d+)\s*(mo|ms|[ydhms])/g)]; + + const time = units.length > 0 ? + units.reduce((total, [, number, unit]) => total + Number(number) * TIME_UNITS[unit], 0) : + input.split(":").reduce((total, part) => total * 60 + Number(part), 0) * MS; + + return Math.round(time); +}; + export const formatRecordTime = (timeMs: number) => timeMs ? formatTime(timeMs) : "N/A"; From 467739a84e19c6c00740e624379ddc79f226b2f2 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:32:38 -0500 Subject: [PATCH 5/9] style(schemas): sort the murder mystery util imports --- packages/schemas/src/player/gamemodes/murdermystery/mode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/schemas/src/player/gamemodes/murdermystery/mode.ts b/packages/schemas/src/player/gamemodes/murdermystery/mode.ts index cd8c65676..76110e4a0 100644 --- a/packages/schemas/src/player/gamemodes/murdermystery/mode.ts +++ b/packages/schemas/src/player/gamemodes/murdermystery/mode.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime, prettify, parseTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime, prettify } from "@statsify/util"; import { Field } from "#metadata"; import { Progression } from "#progression"; import { ratio } from "@statsify/math"; From 8149bea26cacc7d765d51d988dde7c9c581b62d1 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:59:52 -0500 Subject: [PATCH 6/9] fix(schemas): allow a space before the k, m or b suffix in leaderboard searches --- packages/schemas/src/util/parse-leaderboard-value.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/schemas/src/util/parse-leaderboard-value.ts b/packages/schemas/src/util/parse-leaderboard-value.ts index 9f9090e01..d663d2d04 100644 --- a/packages/schemas/src/util/parse-leaderboard-value.ts +++ b/packages/schemas/src/util/parse-leaderboard-value.ts @@ -42,7 +42,7 @@ export function parseLeaderboardValue( if (parser) return parser(search); - const match = /^(\d*\.?\d+)([kmb])?/.exec(search); + const match = /^(\d*\.?\d+)\s*([kmb])?/.exec(search); if (!match) return Number.NaN; const [, number, suffix] = match; @@ -58,6 +58,7 @@ if (import.meta.vitest) { it("parses numbers, times and levels", () => { expect(parseLeaderboardValue(Player, "stats.bedwars.overall.wins", "1.5k")).toBe(1500); + expect(parseLeaderboardValue(Player, "stats.bedwars.overall.wins", "10 k")).toBe(10_000); expect(parseLeaderboardValue(Player, "stats.bedwars.overall.wins", "wins")).toBeNaN(); expect(parseLeaderboardValue(Player, "stats.parkour.BEDWARS", "1:23.45")).toBe(83_450); expect(parseLeaderboardValue(Player, "stats.pit.playtime", "5d, 3h")).toBe(442_800_000); From 183106a36c3fb55eb3c94bd25a3119b1a163f76e Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:23:00 -0500 Subject: [PATCH 7/9] refactor(api): assign the value search position straight to the highlight --- apps/api/src/leaderboards/leaderboard.service.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/api/src/leaderboards/leaderboard.service.ts b/apps/api/src/leaderboards/leaderboard.service.ts index 528628488..d137248d5 100644 --- a/apps/api/src/leaderboards/leaderboard.service.ts +++ b/apps/api/src/leaderboards/leaderboard.service.ts @@ -118,14 +118,13 @@ export abstract class LeaderboardService { break; } case LeaderboardQuery.VALUE: { - const position = await this.searchLeaderboardValue( + highlight = await this.searchLeaderboardValue( constructor, field, input as number, sort ); - highlight = position; - top = position - (position % PAGE_SIZE); + top = highlight - (highlight % PAGE_SIZE); bottom = top + PAGE_SIZE; break; } From e01dbeb71e61be6942ca8cc747a4ac785be332cc Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:23:20 -0500 Subject: [PATCH 8/9] docs(schemas): trim the leaderboard search comments to their contracts --- packages/schemas/src/metadata/metadata.interface.ts | 4 ++-- packages/schemas/src/util/parse-leaderboard-value.ts | 4 +--- packages/util/src/util.ts | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/schemas/src/metadata/metadata.interface.ts b/packages/schemas/src/metadata/metadata.interface.ts index 749fee29e..347641300 100644 --- a/packages/schemas/src/metadata/metadata.interface.ts +++ b/packages/schemas/src/metadata/metadata.interface.ts @@ -111,12 +111,12 @@ export interface LeaderboardEnabledMetadata extends BaseLeaderboardMetadata { limit: number; /** - * Reads a value searched on the leaderboard back into a score, the reverse of the formatter. For example if the field is a time, it will read `1:23.45` as milliseconds + * Reads a leaderboard search back into a score, the reverse of the formatter. */ parser?: (input: string) => number; /** - * Turns a score into what users search the leaderboard by, like the level on an exp leaderboard. It must never go down as the score goes up. + * Turns a score into what users search by, like exp into a level. It must never go down as the score goes up. */ searchBy?: (score: number) => number; } diff --git a/packages/schemas/src/util/parse-leaderboard-value.ts b/packages/schemas/src/util/parse-leaderboard-value.ts index d663d2d04..9167259ab 100644 --- a/packages/schemas/src/util/parse-leaderboard-value.ts +++ b/packages/schemas/src/util/parse-leaderboard-value.ts @@ -11,7 +11,6 @@ import { getLeaderboardField } from "./leaderboard-scanner.js"; const SUFFIXES: Record = { k: 1e3, m: 1e6, b: 1e9 }; -// Binary search for the lowest score that reaches the value, which needs searchBy to never go down function getLowestScore(searchBy: (score: number) => number, value: number) { let low = 0; let high = Number.MAX_SAFE_INTEGER; @@ -29,8 +28,7 @@ function getLowestScore(searchBy: (score: number) => number, value: number) { } /** - * Turns a value searched on a leaderboard, like `10k`, `500✫` or `1:23.45`, into the score the leaderboard is sorted by - * @returns NaN if the value can't be read + * @returns The score a search like `10k`, `500✫` or `1:23.45` sorts at, or NaN if it can't be read */ export function parseLeaderboardValue( constructor: Constructor, diff --git a/packages/util/src/util.ts b/packages/util/src/util.ts index 4c59dbe32..1f1659437 100644 --- a/packages/util/src/util.ts +++ b/packages/util/src/util.ts @@ -193,7 +193,7 @@ const TIME_UNITS: Record = { y: MS * SECONDS * MINUTES * HOURS * MONTHS * YEARS, }; -// Reads back formatTime and formatRaceTime, or seconds like 1:23.45, into milliseconds +// Reads formatTime or formatRaceTime output, or a time like 1:23.45, back into milliseconds export const parseTime = (input: string) => { const units = [...input.matchAll(/(\d*\.?\d+)\s*(mo|ms|[ydhms])/g)]; From d2325deefcb30a0dbda8581d9e5dd56cf06241f5 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:24:16 -0500 Subject: [PATCH 9/9] test(schemas): remove the leaderboard value parser test --- .../schemas/src/util/parse-leaderboard-value.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/schemas/src/util/parse-leaderboard-value.ts b/packages/schemas/src/util/parse-leaderboard-value.ts index 9167259ab..cfa7e1902 100644 --- a/packages/schemas/src/util/parse-leaderboard-value.ts +++ b/packages/schemas/src/util/parse-leaderboard-value.ts @@ -48,19 +48,3 @@ export function parseLeaderboardValue( return searchBy ? getLowestScore(searchBy, value) : value; } - -if (import.meta.vitest) { - const { it, expect } = import.meta.vitest; - - const { Player } = await import("#player"); - - it("parses numbers, times and levels", () => { - expect(parseLeaderboardValue(Player, "stats.bedwars.overall.wins", "1.5k")).toBe(1500); - expect(parseLeaderboardValue(Player, "stats.bedwars.overall.wins", "10 k")).toBe(10_000); - expect(parseLeaderboardValue(Player, "stats.bedwars.overall.wins", "wins")).toBeNaN(); - expect(parseLeaderboardValue(Player, "stats.parkour.BEDWARS", "1:23.45")).toBe(83_450); - expect(parseLeaderboardValue(Player, "stats.pit.playtime", "5d, 3h")).toBe(442_800_000); - expect(parseLeaderboardValue(Player, "stats.bedwars.exp", "500✫")).toBe(2_435_000); - expect(parseLeaderboardValue(Player, "stats.skywars.exp", "20000")).toBeNaN(); - }); -}