diff --git a/src/services/StatisticsService.js b/src/services/StatisticsService.js index 2165cb5..fa6eedd 100644 --- a/src/services/StatisticsService.js +++ b/src/services/StatisticsService.js @@ -760,6 +760,89 @@ function buildStatsTrackTypeKey (trackId, typeId) { return `${trackId}::${typeId}` } +/** + * Determine whether a unified stats row needs a computed global rank fallback. + * Only positive ratings are rankable; missing, zero, and negative persisted ranks + * are treated as unavailable because public legacy marathon data often stores + * unrated rank placeholders as zero. + * @param {Object} row unified memberStats row + * @returns {Boolean} true when the row can be ranked from its rating + */ +function shouldComputeGlobalRank (row) { + if (!row || !row.trackId || !row.typeId || _.isNil(row.rating)) { + return false + } + + const rating = Number(row.rating) + const globalRank = _.isNil(row.globalRank) ? null : Number(row.globalRank) + + return Number.isFinite(rating) && rating > 0 && + (_.isNil(globalRank) || !Number.isFinite(globalRank) || globalRank <= 0) +} + +/** + * Build the cache key for a stats row's rank scope. + * Rows sharing track, type, privacy, and rating share the same computed rank. + * @param {Object} row unified memberStats row + * @returns {String} cache key for computed rank lookups + */ +function buildGlobalRankScopeKey (row) { + return [ + row.trackId, + row.typeId, + row.isPrivate ? 'private' : 'public', + Number(row.rating) + ].join('::') +} + +/** + * Fill invalid or missing globalRank values using current unified ratings. + * The computed value matches SQL RANK semantics: one plus the number of rows in + * the same track/type/privacy scope with a strictly higher positive rating. + * @param {Array} statsRows unified memberStats rows returned for one member + * @returns {Promise>} rows with computed globalRank fallbacks applied + * @throws {Error} propagates Prisma count failures + */ +async function hydrateComputedGlobalRanks (statsRows) { + const rankTargets = _.filter(statsRows, shouldComputeGlobalRank) + if (rankTargets.length === 0) { + return statsRows + } + + const uniqueRankTargets = _.uniqBy(rankTargets, buildGlobalRankScopeKey) + const rankByScope = new Map() + + await Promise.all(_.map(uniqueRankTargets, async (row) => { + const higherRatedCount = await prisma.memberStats.count({ + where: { + trackId: row.trackId, + typeId: row.typeId, + isPrivate: row.isPrivate === true, + rating: { + gt: Number(row.rating) + } + } + }) + + rankByScope.set(buildGlobalRankScopeKey(row), higherRatedCount + 1) + })) + + return _.map(statsRows, (row) => { + const computedRank = shouldComputeGlobalRank(row) + ? rankByScope.get(buildGlobalRankScopeKey(row)) + : undefined + + if (_.isNil(computedRank)) { + return row + } + + return { + ...row, + globalRank: computedRank + } + }) +} + /** * Check whether a resolved challenge type is Marathon Match. * @param {string|undefined} typeName canonical or raw type name @@ -2940,7 +3023,8 @@ async function getUnifiedMemberStats (member, groupIds, query, fields) { }) if (unifiedStats && unifiedStats.length > 0) { - const scopedStats = _.map(annotateUnifiedDimensionRows(unifiedStats, dimensionLookup), stat => ({ + const rankedStats = await hydrateComputedGlobalRanks(unifiedStats) + const scopedStats = _.map(annotateUnifiedDimensionRows(rankedStats, dimensionLookup), stat => ({ ...stat, groupId: _.toNumber(groupId) })) @@ -3016,7 +3100,8 @@ async function getMemberStats (currentUser, handle, query, throwError) { }) if (unifiedStats && unifiedStats.length > 0) { - const scopedStats = _.map(annotateUnifiedDimensionRows(unifiedStats, dimensionLookup), stat => ({ + const rankedStats = await hydrateComputedGlobalRanks(unifiedStats) + const scopedStats = _.map(annotateUnifiedDimensionRows(rankedStats, dimensionLookup), stat => ({ ...stat, groupId: _.toNumber(groupId) })) diff --git a/test/unit/StatisticsService.test.js b/test/unit/StatisticsService.test.js index 5018bc7..88f477d 100644 --- a/test/unit/StatisticsService.test.js +++ b/test/unit/StatisticsService.test.js @@ -227,6 +227,7 @@ function loadStatisticsService (options = {}) { }, memberStats: { findFirst: async () => null, + count: async () => 0, findMany: async () => [] }, memberStatsHistory: { @@ -513,6 +514,60 @@ describe('statistics service unit tests', () => { } }) + it('getMemberStats should compute Marathon Match rank when the stored global rank is zero', async () => { + let countArgs + const { service, restore } = loadStatisticsService({ + prismaStub: { + $queryRaw: async () => [], + memberStats: { + count: async (args) => { + countArgs = args + return 6 + }, + findMany: async () => [ + { + trackId: 'track-ds-id', + typeId: 'type-mm-id', + challenges: 137, + wins: 40, + rating: 2720, + globalRank: 0, + countryRank: 0, + schoolRank: 0, + volatility: 513, + maxRating: 3071, + minRating: 1527, + topFiveFinishes: 86, + topTenFinishes: 102, + mostRecentEventDate: new Date('2023-05-02T00:00:00.000Z'), + isPrivate: false + } + ] + }, + memberStatsHistory: { + findMany: async () => [] + } + } + }) + + try { + const result = await service.getMemberStats({ isMachine: true }, 'devtest1400', {}) + + result.should.have.length(1) + result[0].DATA_SCIENCE.MARATHON_MATCH.rank.rank.should.equal(7) + countArgs.where.should.deep.equal({ + trackId: 'track-ds-id', + typeId: 'type-mm-id', + isPrivate: false, + rating: { + gt: 2720 + } + }) + } finally { + restore() + } + }) + it('rerateMemberStats should route configured rating paths to the Marathon Match engine', async () => { let capturedOptions const { service, restore } = loadStatisticsService({