Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 103 additions & 1 deletion src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { MemberStats, UserStats } from '~/libs/core'
import type { MemberStats, UserStats, UserStatsHistory } from '~/libs/core'

import {
getActiveTracks,
Expand All @@ -10,6 +10,7 @@ import {

jest.mock('~/libs/core', () => ({
useMemberStats: jest.fn(),
useStatsHistory: jest.fn(),
}), {
virtual: true,
})
Expand Down Expand Up @@ -331,6 +332,107 @@ describe('getActiveTracks', () => {
.not.toContain('AI Engineering')
})

it('deduplicates Development totals when AI Engineering and Challenge share challenge history', () => {
const sharedChallengeHistory = [
{
challengeId: 'sales-app-dev-ai-expo',
challengeName: 'Sales App dev AI expo',
newRating: 1200,
placement: 1,
ratingDate: 1781237773026,
},
{
challengeId: 'sales-app-dev-ai',
challengeName: 'Sales App dev AI',
newRating: 1200,
placement: 1,
ratingDate: 1781237773027,
},
]
const statsHistory = {
DATA_SCIENCE: {
'AI Engineering': {
history: [
{
challengeId: 'dev-mm-with-ai',
challengeName: 'Dev MM with AI',
newRating: 1200,
placement: 1,
ratingDate: 1781237773021,
},
{
challengeId: 'ds-mm-with-ai-exponential-league',
challengeName: 'DS MM with AI Exponential League',
newRating: 1200,
placement: 1,
ratingDate: 1781237773022,
},
{
challengeId: 'ds-with-ai-exponential-league',
challengeName: 'DS with AI Exponential League',
newRating: 1200,
placement: 1,
ratingDate: 1781237773023,
},
{
challengeId: 'sales-app-ds-ai',
challengeName: 'Sales App DS AI',
newRating: 1200,
placement: 1,
ratingDate: 1781237773024,
},
...sharedChallengeHistory,
],
},
},
DEVELOP: {
subTracks: [
{
history: sharedChallengeHistory,
name: 'Challenge',
},
],
},
} as unknown as UserStatsHistory
const activeTracks: MemberStatsTrack[] = getActiveTracks({
DATA_SCIENCE: {
'AI Engineering': {
challenges: 6,
rank: {
rating: 1200,
},
submissions: {
submissions: 6,
},
wins: 6,
},
},
DEVELOP: {
subTracks: [
{
challenges: 2,
name: 'Challenge',
submissions: {
submissions: 2,
},
wins: 2,
},
],
},
} as unknown as UserStats, statsHistory)
const developmentTrack: MemberStatsTrack | undefined = activeTracks
.find(track => track.name === 'Development')

expect(developmentTrack)
.toEqual(expect.objectContaining({
challenges: 6,
submissions: 6,
wins: 6,
}))
expect(developmentTrack?.subTracks.map(track => track.name))
.toEqual(['Challenge', 'AI Engineering'])
})

it('keeps rated custom non-AI data science paths visible as member stats tracks', () => {
const activeTracks: MemberStatsTrack[] = getActiveTracks({
challenges: 5,
Expand Down
146 changes: 133 additions & 13 deletions src/apps/profiles/src/hooks/useFetchActiveTracks.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { filter, find, get, orderBy } from 'lodash'
import { filter, find, get, orderBy, sumBy } from 'lodash'

import {
DataScienceRatingPathStats,
Expand All @@ -9,10 +9,14 @@ import {
StatsHistory,
useMemberStats,
UserStats,
UserStatsHistory,
useStatsHistory,
} from '~/libs/core'

import { calcProportionalAverage } from '../lib/math.utils'

import { getTrackHistoryFromStats } from './useTrackHistory'

const testingSubTrackNames = new Set([
'BUG_HUNT',
'TEST_SCENARIOS',
Expand Down Expand Up @@ -63,6 +67,10 @@ export interface SubTrackSummaryStats {
wins: number
}

export interface TrackSummaryStats extends SubTrackSummaryStats {
challenges: number
}

const getFiniteNumber = (value: unknown): number | undefined => (
typeof value === 'number' && Number.isFinite(value) ? value : undefined
)
Expand Down Expand Up @@ -162,6 +170,113 @@ const isTestingSubTrack = (subTrack?: MemberStats): boolean => (
!!subTrack?.name && testingSubTrackNames.has(subTrack.name)
)

const getSubTrackDisplayChallengeCount = (subTrack?: MemberStats): number => (
getFiniteNumber(subTrack?.challenges) ?? 0
)

const getHistoryChallengeKey = (history: StatsHistory): string => [
history.challengeId,
history.challengeName,
history.ratingDate ?? history.date,
].map(value => String(value ?? ''))
.join('::')

interface SubTrackHistorySummary {
history: StatsHistory[]
stats: SubTrackSummaryStats
subTrack: MemberStats
}

const getSubTrackHistorySummaries = (
subTracks: MemberStats[],
statsHistory?: UserStatsHistory,
): SubTrackHistorySummary[] => (
subTracks.map(subTrack => {
const history = statsHistory ? getTrackHistoryFromStats(statsHistory, subTrack) : []

return {
history,
stats: getSubTrackSummaryStats(subTrack, history),
subTrack,
}
})
)

const getFallbackTrackSummaryStats = (summaries: SubTrackHistorySummary[]): TrackSummaryStats => ({
challenges: sumBy(summaries, summary => getSubTrackDisplayChallengeCount(summary.subTrack)),
submissions: sumBy(summaries, summary => summary.stats.submissions),
wins: sumBy(summaries, summary => summary.stats.wins),
})

/**
* Builds parent track totals from subtracks, de-duplicating by challenge history when available.
*
* Development can show the same AI challenge under both `Challenge` and
* `AI Engineering`. When history rows are available, duplicate challenge ids are
* counted once for the parent totals while each child card keeps its own stats.
*
* @param {MemberStats[]} subTracks - Active subtracks included in the parent track.
* @param {UserStatsHistory | undefined} statsHistory - Optional stats-history payload for the same member.
* @returns {TrackSummaryStats} Parent challenge, win, and submission totals for display.
*/
export const getTrackSummaryStats = (
subTracks: MemberStats[],
statsHistory?: UserStatsHistory,
): TrackSummaryStats => {
const summaries = getSubTrackHistorySummaries(subTracks, statsHistory)

if (!statsHistory) {
return getFallbackTrackSummaryStats(summaries)
}

const historySummaries = summaries.filter(summary => summary.history.length > 0)

if (historySummaries.length === 0) {
return getFallbackTrackSummaryStats(summaries)
}

const uniqueHistoryByChallenge = new Map<string, StatsHistory>()
let hasDuplicateHistory = false

historySummaries.forEach(summary => {
summary.history.forEach(history => {
const key = getHistoryChallengeKey(history)
const existingHistory = uniqueHistoryByChallenge.get(key)

if (existingHistory) {
hasDuplicateHistory = true
}

if (!existingHistory || existingHistory.placement !== 1) {
uniqueHistoryByChallenge.set(key, history)
}
})
})

const uniqueHistory = Array.from(uniqueHistoryByChallenge.values())
const noHistorySummaryStats = getFallbackTrackSummaryStats(
summaries.filter(summary => summary.history.length === 0),
)
const historyChallengeExtras = sumBy(historySummaries, summary => Math.max(
0,
getSubTrackDisplayChallengeCount(summary.subTrack) - summary.history.length,
))
const historySubmissionExtras = sumBy(historySummaries, summary => Math.max(
0,
summary.stats.submissions - summary.history.length,
))
const uniqueHistoryWins = uniqueHistory.filter(history => history.placement === 1).length
const historyStatsWins = hasDuplicateHistory
? Math.max(...historySummaries.map(summary => summary.stats.wins))
: sumBy(historySummaries, summary => summary.stats.wins)

return {
challenges: uniqueHistory.length + historyChallengeExtras + noHistorySummaryStats.challenges,
submissions: uniqueHistory.length + historySubmissionExtras + noHistorySummaryStats.submissions,
wins: (uniqueHistoryWins > 0 ? uniqueHistoryWins : historyStatsWins) + noHistorySummaryStats.wins,
}
}

/**
* Pick the Data Science subtrack rating used by the summary card.
*
Expand Down Expand Up @@ -279,27 +394,27 @@ export const getMemberChallengePoints = (memberStats?: UserStats): number | unde
*
* @param {string} trackName - The name of the track.
* @param {MemberStats[]} subTracks - List of subtracks within the main track.
* @param {UserStatsHistory | undefined} statsHistory - Optional history used to de-duplicate parent totals.
* @returns {MemberStatsTrack} - Aggregated data for the track.
*/
const buildTrackData = (trackName: string, allSubTracks: MemberStats[]): MemberStatsTrack => {
const buildTrackData = (
trackName: string,
allSubTracks: MemberStats[],
statsHistory?: UserStatsHistory,
): MemberStatsTrack => {
const subTracks = allSubTracks.filter(isActiveSubTrack)
// Calculate total wins, challenges, and submissions for the track
const totalWins = subTracks.reduce((sum, subTrack) => (sum + (subTrack?.wins || 0)), 0)
const challengesCount = subTracks.reduce((sum, subTrack) => (sum + (subTrack?.challenges || 0)), 0)
const submissionsCount = subTracks.reduce((sum, subTrack) => (
sum + (getSubTrackDisplaySubmissionCount(subTrack) ?? 0)
), 0)
const summaryStats = getTrackSummaryStats(subTracks, statsHistory)
const hasSubmissionCounts = subTracks.some(subTrack => getSubTrackDisplaySubmissionCount(subTrack) !== undefined)

// Return aggregated track data
return {
challenges: challengesCount,
challenges: summaryStats.challenges,
isActive: subTracks.length > 0,
name: trackName,
order: 1,
submissions: hasSubmissionCounts ? submissionsCount : undefined,
submissions: hasSubmissionCounts ? summaryStats.submissions : undefined,
subTracks,
wins: totalWins,
wins: summaryStats.wins,
}
}

Expand Down Expand Up @@ -574,7 +689,10 @@ const getDataScienceRatingPathTrackData = (memberStats?: UserStats): MemberStats
* @param {UserStats | undefined} memberStats - The raw stats payload for the user.
* @returns {MemberStatsTrack[]} - List of active tracks for the user.
*/
export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => {
export const getActiveTracks = (
memberStats?: UserStats,
statsHistory?: UserStatsHistory,
): MemberStatsTrack[] => {
// Create mappings for data science subtracks
const dataScienceSubTracks: {[key: string]: MemberStats | SRMStats} = {
// Map Challenge subtrack
Expand Down Expand Up @@ -641,6 +759,7 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] =>
: [aiEngineeringDevelopmentSubTrack]),
]
.filter(subTrack => !isTestingSubTrack(subTrack)),
statsHistory,
)
)

Expand Down Expand Up @@ -710,8 +829,9 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] =>
*/
export const useFetchActiveTracks = (userHandle: string): MemberStatsTrack[] => {
const memberStats: UserStats | undefined = useMemberStats(userHandle)
const statsHistory: UserStatsHistory | undefined = useStatsHistory(userHandle)

return useMemo(() => getActiveTracks(memberStats), [memberStats])
return useMemo(() => getActiveTracks(memberStats, statsHistory), [memberStats, statsHistory])
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,31 @@ describe('MemberRatingCard', () => {
.toBeInTheDocument()
})

it('shows top one percent when the member rating is above the highest distribution range', () => {
mockedUseMemberStats.mockReturnValue({
DATA_SCIENCE: {
MARATHON_MATCH: {
mostRecentEventDate: 1000,
rank: {
rating: 4051,
},
},
},
maxRating: {
rating: 4051,
},
} as unknown as UserStats)

render(<MemberRatingCard {...defaultProps} />)

expect(screen.getByText('4051'))
.toBeInTheDocument()
expect(screen.getByText('Top 1%'))
.toBeInTheDocument()
expect(screen.getByText('Data Scientists'))
.toBeInTheDocument()
})

it('disables the percentile tooltip while the rating modal is open', () => {
mockedUseMemberStats.mockReturnValue({
DATA_SCIENCE: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ describe('calculateTopPercentileFromDistribution', () => {
.toBeCloseTo(55)
})

it('returns the top known member percentage when the rating is above the highest range', () => {
expect(calculateTopPercentileFromDistribution(distribution, 4051))
.toBeCloseTo(0.1)
})

it('returns undefined when the rating or distribution cannot be used', () => {
expect(calculateTopPercentileFromDistribution(distribution, undefined))
.toBeUndefined()
Expand Down
Loading
Loading