From b408426ce39f180214df56ae0b0ff3f693765272 Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 12 May 2026 05:16:06 -0600 Subject: [PATCH 1/3] fix: stabilize guild monthly leaderboards --- apps/api/src/guild/guild.service.ts | 57 ++++++++++++++++++- .../leaderboards/guild-leaderboard.service.ts | 4 +- apps/api/vitest.config.ts | 11 ++++ 3 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 apps/api/vitest.config.ts diff --git a/apps/api/src/guild/guild.service.ts b/apps/api/src/guild/guild.service.ts index a13129c3d..963ae871f 100644 --- a/apps/api/src/guild/guild.service.ts +++ b/apps/api/src/guild/guild.service.ts @@ -117,8 +117,10 @@ export class GuildService { .lean() .exec(); + const combinedGuildExpHistory = this.getCombinedExpHistory(cachedGuild, guildExpHistory); + // Get scaled gexp - Object.entries(guildExpHistory) + Object.entries(combinedGuildExpHistory) .sort() .toReversed() .slice(0, 30) @@ -255,9 +257,62 @@ export class GuildService { } } + private getExpHistory(days: string[] = [], expHistory: number[] = []) { + return Object.fromEntries(days.map((day, index) => [day, expHistory[index] ?? 0])); + } + + private getCombinedExpHistory( + cachedGuild: Guild | null, + guildExpHistory: Record + ) { + // Preserve cached guild totals so GEXP does not drop when member history disappears. + const combinedGuildExpHistory = this.getExpHistory( + cachedGuild?.expHistoryDays, + cachedGuild?.expHistory + ); + + Object.entries(guildExpHistory).forEach(([day, exp]) => { + combinedGuildExpHistory[day] = Math.max(combinedGuildExpHistory[day] ?? 0, exp); + }); + + return combinedGuildExpHistory; + } + private scaleGexp(exp: number) { if (exp <= 200_000) return exp; if (exp <= 700_000) return (exp - 200_000) / 10 + 200_000; return Math.round((exp - 700_000) / 33 + 250_000); } } + +if (import.meta.vitest) { + const { suite, it, expect } = import.meta.vitest; + + suite("GuildService", () => { + it("preserves cached guild exp history when refreshed member totals are lower", () => { + const service = Object.create(GuildService.prototype) as { + getCombinedExpHistory: ( + cachedGuild: Pick, + guildExpHistory: Record + ) => Record; + }; + + const combined = service.getCombinedExpHistory( + { + expHistoryDays: ["2026-05-12", "2026-05-11", "2026-05-10"], + expHistory: [500, 400, 300], + } as Guild, + { + "2026-05-12": 250, + "2026-05-11": 450, + } + ); + + expect(combined).toEqual({ + "2026-05-12": 500, + "2026-05-11": 450, + "2026-05-10": 300, + }); + }); + }); +} diff --git a/apps/api/src/guild/leaderboards/guild-leaderboard.service.ts b/apps/api/src/guild/leaderboards/guild-leaderboard.service.ts index 430f00294..7049952db 100644 --- a/apps/api/src/guild/leaderboards/guild-leaderboard.service.ts +++ b/apps/api/src/guild/leaderboards/guild-leaderboard.service.ts @@ -67,8 +67,8 @@ export class GuildLeaderboardService extends LeaderboardService { .lean() .exec(); - const additionalStats = flatten(guild) as LeaderboardAdditionalStats; - additionalStats.name = additionalStats.nameFormatted; + const additionalStats = flatten(guild ?? {}) as LeaderboardAdditionalStats; + additionalStats.name = additionalStats.nameFormatted ?? guild?.name ?? id; return additionalStats; }) diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 000000000..2f7603e59 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,11 @@ +/** + * 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 { config } from "../../vitest.shared.js"; + +export default await config(); From be136caf2a1cab925399ed914b9db4eb0be43747 Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 12 May 2026 06:04:08 -0600 Subject: [PATCH 2/3] perf(api): reduce guild gexp merge allocations --- apps/api/src/guild/guild.service.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/api/src/guild/guild.service.ts b/apps/api/src/guild/guild.service.ts index 963ae871f..82e2b6ed2 100644 --- a/apps/api/src/guild/guild.service.ts +++ b/apps/api/src/guild/guild.service.ts @@ -84,10 +84,7 @@ export class GuildService { // Merge the exp history from hypixel and the cached guild const combinedExpHistory: Record = { - ...cacheMember?.expHistoryDays?.reduce( - (acc, day, index) => ({ ...acc, [day]: cacheMember.expHistory[index] }), - {} - ), + ...this.getExpHistory(cacheMember?.expHistoryDays, cacheMember?.expHistory), ...Object.fromEntries( member.expHistoryDays.map((day, index) => [day, member.expHistory[index]]) ), From 85256d1135761e89eb683c432d0051fa56ae994b Mon Sep 17 00:00:00 2001 From: Cody Date: Sat, 30 May 2026 04:02:17 -0600 Subject: [PATCH 3/3] fix(api): clean up guild exp history merge --- apps/api/src/guild/guild.service.ts | 62 ++++++++----------- .../leaderboards/guild-leaderboard.service.ts | 1 + apps/api/tsconfig.json | 5 +- 3 files changed, 31 insertions(+), 37 deletions(-) diff --git a/apps/api/src/guild/guild.service.ts b/apps/api/src/guild/guild.service.ts index 82e2b6ed2..a8492b9c7 100644 --- a/apps/api/src/guild/guild.service.ts +++ b/apps/api/src/guild/guild.service.ts @@ -22,6 +22,24 @@ import { PlayerService } from "#player"; import { flatten } from "@statsify/util"; import type { ReturnModelType } from "@typegoose/typegoose"; +function getExpHistory(days: string[] = [], expHistory: number[] = []) { + return Object.fromEntries(days.map((day, index) => [day, expHistory[index] ?? 0])); +} + +function mergeExpHistory( + cachedDays: string[] | undefined, + cachedExpHistory: number[] | undefined, + currentExpHistory: Record +) { + const combinedExpHistory = getExpHistory(cachedDays, cachedExpHistory); + + Object.entries(currentExpHistory).forEach(([day, exp]) => { + combinedExpHistory[day] = Math.max(combinedExpHistory[day] ?? 0, exp); + }); + + return combinedExpHistory; +} + @Injectable() export class GuildService { private readonly logger = new Logger("GuildService"); @@ -84,7 +102,7 @@ export class GuildService { // Merge the exp history from hypixel and the cached guild const combinedExpHistory: Record = { - ...this.getExpHistory(cacheMember?.expHistoryDays, cacheMember?.expHistory), + ...getExpHistory(cacheMember?.expHistoryDays, cacheMember?.expHistory), ...Object.fromEntries( member.expHistoryDays.map((day, index) => [day, member.expHistory[index]]) ), @@ -114,7 +132,11 @@ export class GuildService { .lean() .exec(); - const combinedGuildExpHistory = this.getCombinedExpHistory(cachedGuild, guildExpHistory); + const combinedGuildExpHistory = mergeExpHistory( + cachedGuild?.expHistoryDays, + cachedGuild?.expHistory, + guildExpHistory + ); // Get scaled gexp Object.entries(combinedGuildExpHistory) @@ -254,27 +276,6 @@ export class GuildService { } } - private getExpHistory(days: string[] = [], expHistory: number[] = []) { - return Object.fromEntries(days.map((day, index) => [day, expHistory[index] ?? 0])); - } - - private getCombinedExpHistory( - cachedGuild: Guild | null, - guildExpHistory: Record - ) { - // Preserve cached guild totals so GEXP does not drop when member history disappears. - const combinedGuildExpHistory = this.getExpHistory( - cachedGuild?.expHistoryDays, - cachedGuild?.expHistory - ); - - Object.entries(guildExpHistory).forEach(([day, exp]) => { - combinedGuildExpHistory[day] = Math.max(combinedGuildExpHistory[day] ?? 0, exp); - }); - - return combinedGuildExpHistory; - } - private scaleGexp(exp: number) { if (exp <= 200_000) return exp; if (exp <= 700_000) return (exp - 200_000) / 10 + 200_000; @@ -287,18 +288,9 @@ if (import.meta.vitest) { suite("GuildService", () => { it("preserves cached guild exp history when refreshed member totals are lower", () => { - const service = Object.create(GuildService.prototype) as { - getCombinedExpHistory: ( - cachedGuild: Pick, - guildExpHistory: Record - ) => Record; - }; - - const combined = service.getCombinedExpHistory( - { - expHistoryDays: ["2026-05-12", "2026-05-11", "2026-05-10"], - expHistory: [500, 400, 300], - } as Guild, + const combined = mergeExpHistory( + ["2026-05-12", "2026-05-11", "2026-05-10"], + [500, 400, 300], { "2026-05-12": 250, "2026-05-11": 450, diff --git a/apps/api/src/guild/leaderboards/guild-leaderboard.service.ts b/apps/api/src/guild/leaderboards/guild-leaderboard.service.ts index 7049952db..674b34914 100644 --- a/apps/api/src/guild/leaderboards/guild-leaderboard.service.ts +++ b/apps/api/src/guild/leaderboards/guild-leaderboard.service.ts @@ -56,6 +56,7 @@ export class GuildLeaderboardService extends LeaderboardService { }, {} as Record); selector.nameFormatted = true; + selector.name = true; return await Promise.all( ids.map(async (id) => { diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index f1e6b476d..06810fb21 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.base.json", "include": [ "src", - "eslint.config.js" + "eslint.config.js", + "vitest.config.ts" ] -} \ No newline at end of file +}