diff --git a/apps/api/src/guild/guild.service.ts b/apps/api/src/guild/guild.service.ts index a13129c3d..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]]) ), @@ -117,8 +114,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 +254,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();