From 670fdf00b0e4a2d5871317dd0bb3ae08a85c24e3 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 4 Jun 2026 10:25:13 +1000 Subject: [PATCH 01/73] Initial AI ratings implementation --- .../MemberStatsBlock.module.scss | 138 +++++++++--- .../MemberStatsBlock/MemberStatsBlock.tsx | 201 ++++++++++++------ .../src/hooks/useFetchActiveTracks.spec.tsx | 31 ++- .../src/hooks/useFetchActiveTracks.tsx | 86 +++++++- .../MemberRatingCard.module.scss | 52 +++-- .../MemberRatingCard/MemberRatingCard.tsx | 64 +++++- .../MemberRatingInfoModal.module.scss | 44 ++++ .../MemberRatingInfoModal.tsx | 81 ++++++- src/libs/core/lib/profile/user-stats.model.ts | 17 ++ 9 files changed, 582 insertions(+), 132 deletions(-) create mode 100644 src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss index a3b4e9233..f499c89f6 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss @@ -6,15 +6,67 @@ container-type: inline-size; } +.challengePointsBar { + display: flex; + align-items: center; + gap: $sp-1; + min-height: 34px; + padding: 0 $sp-4; + border-radius: 8px 8px 0 0; + background: linear-gradient(90deg, #008A72, #005B86); + color: $tc-white; + font-family: $font-roboto; + font-size: 12px; + line-height: 16px; + + @include ltesm { + flex-wrap: wrap; + padding: $sp-2 $sp-3; + } +} + +.challengePointsLabel { + font-weight: $font-weight-bold; +} + +.challengePointsValue { + font-family: $font-barlow-condensed; + font-size: 20px; + font-weight: $font-weight-medium; + line-height: 22px; +} + +.challengePointsMeta { + color: rgba($tc-white, 0.78); +} + +.challengePointsLink { + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: auto; + font-weight: $font-weight-bold; + white-space: nowrap; + + @include ltesm { + width: 100%; + margin-left: 0; + } +} + .container { display: flex; flex-direction: column; - border-radius: 15px; - background-image: linear-gradient(90deg, #7B21A7, #1974AD); + border-radius: 8px; + background-image: linear-gradient(112deg, #7A2FB0 0%, #0B5D9E 100%); color: $tc-white; - padding: $sp-8; + padding: $sp-5 $sp-5 $sp-4; min-height: 100%; + .challengePointsBar + & { + border-radius: 0 0 8px 8px; + } + @include ltelg { padding: $sp-4; } @@ -26,11 +78,17 @@ .sectionTitle { text-align: center; - margin-bottom: $sp-3; + margin-bottom: $sp-2; } .footerNote { - margin-top: $sp-4; + margin-top: $sp-3; + color: rgba($tc-white, 0.88); + + :global(.body-main) { + font-size: 11px; + line-height: 16px; + } } .innerWrapper { @@ -41,75 +99,95 @@ } .statsList { - display: flex; - flex-wrap: wrap; - gap: $sp-1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + + margin: 0; + + @container (max-width: 360px) { + grid-template-columns: 1fr; + } - margin: auto 0; + > li { + min-width: 0; + } } .trackListItem { display: flex; align-items: center; justify-content: space-between; - min-height: 62px; + min-height: 41px; padding: $sp-2 $sp-3; - background: rgba($tc-white, 0.05); - border: 1px solid rgba($tc-white, 0.25); - border-radius: $sp-2; + border: 1px solid rgba($tc-white, 0.12); + border-width: 1px 0 0; transition: 0.15s ease-in; cursor: pointer; - - - @container (max-width: 582px) { - flex: 1 1 auto; - width: 100%; - } - @container (min-width: 583px) { - width: calc(50% - 2px); - } + color: $tc-white; &:hover { - background: rgba($tc-white, 0.15); - border-color: rgba($tc-white, 0.35); + background: rgba($tc-white, 0.08); } } .trackDetails { display: flex; align-items: center; + min-width: 84px; + > svg { flex: 0 0 auto; } } .rightArrowIcon { - margin-left: $sp-3; + margin-left: $sp-1; + color: rgba($tc-white, 0.65); +} + +.winnerIcon { + color: #F2C900; + margin-right: $sp-1; } .trackStats { display: flex; flex-direction: column; text-align: right; - margin-left: $sp-1; + min-width: 42px; + .count { font-family: $font-barlow-condensed; font-weight: $font-weight-medium; - font-size: 26px; - line-height: 34px; + font-size: 22px; + line-height: 24px; } + .label { font-family: $font-roboto; font-weight: $font-weight-medium; - font-size: 11px; + font-size: 10px; line-height: 12px; + color: rgba($tc-white, 0.82); } } .icon { display: block; - @include icon-xxl; + @include icon-lg; background: currentColor; border-radius: 50%; + margin-right: $sp-1; +} + +.trackName { + min-width: 0; + padding-right: $sp-2; + overflow: hidden; + font-family: $font-roboto; + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; } diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx index cb8a35767..7ec02cdf2 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx @@ -1,11 +1,11 @@ -import { FC, useCallback } from 'react' +import { FC, useCallback, useMemo } from 'react' import { Link } from 'react-router-dom' import classNames from 'classnames' -import { getRatingColor, MemberStats, UserProfile } from '~/libs/core' +import { getRatingColor, MemberStats, useMemberStats, UserProfile, UserStats } from '~/libs/core' import { IconOutline } from '~/libs/ui' -import { useFetchActiveTracks } from '../../../hooks' +import { getActiveTracks, getMemberChallengePoints, MemberStatsTrack } from '../../../hooks' import { formatPlural, WinnerIcon } from '../../../lib' import { MemberProfileContextValue, useMemberProfileContext } from '../../../member-profile/MemberProfile.context' @@ -15,18 +15,122 @@ interface MemberStatsBlockProps { profile: UserProfile } +interface TrackDisplayStats { + indicator?: 'rating' | 'winner' + label: string + value: number +} + +const trackDisplayOrder = [ + 'AI Engineering', + 'Development', + 'Design', + 'Testing', + 'Data Science', + 'Competitive Programming', +] + +const numberFormatter = new Intl.NumberFormat('en-US') + +/** + * Formats profile stats numbers with the comma grouping used in the profile cards. + * + * @param {number} value - The stat value to format. + * @returns {string} The locale-formatted stat value. + */ +const formatStatValue = (value: number): string => numberFormatter.format(value) + +/** + * Returns the stat value, label, and icon treatment for a member stats track. + * + * @param {MemberStatsTrack} track - Aggregated stats for a Topcoder track. + * @returns {TrackDisplayStats} Display metadata for the compact member stats card. + */ +const getTrackDisplayStats = (track: MemberStatsTrack): TrackDisplayStats => { + if (track.rating) { + return { + indicator: 'rating', + label: 'Rating', + value: track.rating, + } + } + + if (track.wins > 0) { + return { + indicator: 'winner', + label: formatPlural(track.wins, 'Win'), + value: track.wins, + } + } + + const submissions = track.submissions ?? 0 + if (submissions > 0) { + return { + label: formatPlural(submissions, 'Submission'), + value: submissions, + } + } + + return { + label: formatPlural(track.challenges ?? 0, 'Challenge'), + value: track.challenges ?? 0, + } +} + +/** + * Sorts tracks into the Figma member-stats card order while keeping unknown tracks visible. + * + * @param {MemberStatsTrack[]} tracks - Aggregated active tracks. + * @returns {MemberStatsTrack[]} Tracks in display order. + */ +const sortTracksForDisplay = (tracks: MemberStatsTrack[]): MemberStatsTrack[] => ( + [...tracks].sort((trackA, trackB) => { + const trackAIndex = trackDisplayOrder.indexOf(trackA.name) + const trackBIndex = trackDisplayOrder.indexOf(trackB.name) + + return (trackAIndex === -1 ? Number.MAX_SAFE_INTEGER : trackAIndex) + - (trackBIndex === -1 ? Number.MAX_SAFE_INTEGER : trackBIndex) + }) +) + const MemberStatsBlock: FC = props => { const { statsRoute }: MemberProfileContextValue = useMemberProfileContext() - const activeTracks = useFetchActiveTracks(props.profile.handle) + const memberStats: UserStats | undefined = useMemberStats(props.profile.handle) + const activeTracks = useMemo(() => getActiveTracks(memberStats), [memberStats]) + const displayTracks = useMemo(() => sortTracksForDisplay(activeTracks), [activeTracks]) + const challengePoints = useMemo(() => getMemberChallengePoints(memberStats), [memberStats]) const getTrackRoute = useCallback((trackName: string, subTracks?: MemberStats[]): string => { const subTrackName = subTracks?.length === 1 ? subTracks[0].name : '' return statsRoute(props.profile.handle, trackName, subTrackName) }, [props.profile.handle, statsRoute]) - return activeTracks?.length === 0 ? <> : ( + return displayTracks?.length === 0 ? <> : (
+ {challengePoints !== undefined && ( +
+ + Challenge Points + + + {formatStatValue(challengePoints)} + + {memberStats?.challenges !== undefined && ( + + from + {' '} + {formatStatValue(memberStats.challenges)} + {' '} + {formatPlural(memberStats.challenges, 'challenge')} + + )} + + View breakdown + + +
+ )}

@@ -35,66 +139,37 @@ const MemberStatsBlock: FC = props => {

    - {activeTracks.map(track => ( - - {track.name} -
    - {!track.isDSTrack && ((track.submissions || track.wins) > 0) && ( - <> - {track.wins > 0 && ( - - )} - - - {track.wins || track.submissions} - - - {formatPlural( - track.wins || track.submissions || 0, - track.wins > 0 ? 'Win' : 'Submission', - )} - + {displayTracks.map(track => ( +
  • + + {track.name} +
    + {getTrackDisplayStats(track).indicator === 'winner' && ( + + )} + {getTrackDisplayStats(track).indicator === 'rating' && ( + + )} + + + {formatStatValue(getTrackDisplayStats(track).value)} - - )} - {/* competitive programming only */} - {track.isDSTrack && ( - (track.isCPTrack || (track.percentile as number) < 50) ? ( - <> - - - - {track.rating} - - - Rating - - - - ) : ( - - - {track.percentile} - % - - - Percentile - + + {getTrackDisplayStats(track).label} - ) - )} - -
    - + + +
  • + + ))}

diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx index 1023542d2..d3c232126 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx @@ -1,6 +1,6 @@ import type { UserStats } from '~/libs/core' -import { getActiveTracks, MemberStatsTrack } from './useFetchActiveTracks' +import { getActiveTracks, getMemberChallengePoints, MemberStatsTrack } from './useFetchActiveTracks' jest.mock('~/libs/core', () => ({ useMemberStats: jest.fn(), @@ -111,4 +111,33 @@ describe('getActiveTracks', () => { expect(testingTrackNames) .toEqual(['BUG_HUNT']) }) + + it('keeps AI engineering stats visible when the API returns them', () => { + const memberStats = { + AI_ENGINEERING: { + challenges: 14, + rank: { + overallPercentile: 15, + rating: 101, + }, + submissions: { + submissions: 100, + }, + }, + challengePoints: 2847, + } as UserStats + const activeTracks: MemberStatsTrack[] = getActiveTracks(memberStats) + const aiTrack: MemberStatsTrack | undefined = activeTracks.find(track => track.name === 'AI Engineering') + + expect(aiTrack) + .toEqual(expect.objectContaining({ + challenges: 14, + isActive: true, + percentile: 15, + rating: 101, + submissions: 100, + })) + expect(getMemberChallengePoints(memberStats)) + .toBe(2847) + }) }) diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx index 34628707b..c7094ffcf 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { filter, find, get, orderBy } from 'lodash' -import { MemberStats, SRMStats, useMemberStats, UserStats } from '~/libs/core' +import { MemberStats, MemberStatsGroup, SRMStats, useMemberStats, UserStats } from '~/libs/core' import { calcProportionalAverage } from '../lib/math.utils' @@ -24,6 +24,7 @@ export interface MemberStatsTrack { percentile?: number, submissionRate?: number screeningSuccessRate?: number + challengePoints?: number wins: number, order?: number isDSTrack?: boolean @@ -82,7 +83,7 @@ const isTestingSubTrack = (subTrack?: MemberStats): boolean => ( * @returns {{[key: string]: MemberStats}} Map of subtracks keyed by subtrack name. */ const mapSubTracksByName = ( - parentTrack: 'DESIGN' | 'DEVELOP', + parentTrack: string, subTracks?: MemberStats[], ): {[key: string]: MemberStats} => ( subTracks?.reduce((all, subTrack) => { @@ -96,6 +97,41 @@ const mapSubTracksByName = ( }, {} as {[key: string]: MemberStats}) ?? {} ) +const getFiniteNumber = (value: unknown): number | undefined => ( + typeof value === 'number' && Number.isFinite(value) ? value : undefined +) + +/** + * Returns the AI Engineering stats payload from the known API keys. + * + * @param {UserStats | undefined} memberStats - The raw stats payload for the user. + * @returns {MemberStatsGroup | undefined} AI Engineering stats when the API includes them. + */ +const getAIEngineeringStats = (memberStats?: UserStats): MemberStatsGroup | undefined => ( + memberStats?.AI_ENGINEERING ?? memberStats?.AI ?? memberStats?.AI_ENGINEER +) + +/** + * Returns the member's total challenge points from the known API keys. + * + * @param {UserStats | undefined} memberStats - The raw stats payload for the user. + * @returns {number | undefined} Challenge points when the API includes them. + */ +export const getMemberChallengePoints = (memberStats?: UserStats): number | undefined => { + const stats = memberStats as ( + UserStats & { + challengePointsTotal?: number + points?: number + } + ) + + return getFiniteNumber(stats?.challengePoints) + ?? getFiniteNumber(stats?.CHALLENGE_POINTS) + ?? getFiniteNumber(stats?.challengePointsTotal) + ?? getFiniteNumber(stats?.points) + ?? getFiniteNumber(getAIEngineeringStats(memberStats)?.challengePoints) +} + /** * Helper function to build aggregated data for a track. * @@ -149,6 +185,48 @@ const enhanceDesignTrackData = (trackData: MemberStatsTrack): MemberStatsTrack = } } +/** + * Builds the AI Engineering aggregate stats row from a top-level API payload. + * + * @param {UserStats | undefined} memberStats - The raw stats payload for the user. + * @returns {MemberStatsTrack} Aggregated AI Engineering stats for the member stats UI. + */ +const buildAIEngineeringTrackData = (memberStats?: UserStats): MemberStatsTrack => { + const aiStats = getAIEngineeringStats(memberStats) + const subTracks: MemberStats[] = aiStats?.subTracks?.length ? ( + Object.values(mapSubTracksByName('AI_ENGINEERING', aiStats.subTracks)) + ) : (aiStats ? [{ + ...(aiStats as MemberStats), + name: aiStats.name ?? 'AI_ENGINEERING', + parentTrack: 'AI_ENGINEERING', + path: 'AI_ENGINEERING', + }] : []) + + const trackData = buildTrackData('AI Engineering', subTracks) + const submissions = getSubTrackSubmissionCount(aiStats as MemberStats | undefined) ?? trackData.submissions + const challenges = getFiniteNumber(aiStats?.challenges) ?? trackData.challenges + const rating = getFiniteNumber(aiStats?.rank?.rating) + const wins = getFiniteNumber(aiStats?.wins) ?? trackData.wins + + return { + ...trackData, + challengePoints: getFiniteNumber(aiStats?.challengePoints), + challenges, + isActive: trackData.isActive + || !!rating + || !!challenges + || !!submissions + || !!wins, + name: 'AI Engineering', + order: 2, + percentile: getFiniteNumber(aiStats?.rank?.overallPercentile) ?? getFiniteNumber(aiStats?.rank?.percentile), + rating, + submissions, + subTracks, + wins, + } +} + /** * Custom hook to fetch active tracks for a user, sorted by wins & submissions. * @@ -188,6 +266,9 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => ) // Build aggregated stats for Design, Development, Testing, and Competitive Programming tracks + // AI Engineering + const aiEngineeringTrackStats: MemberStatsTrack = buildAIEngineeringTrackData(memberStats) + // Design const designTrackStats: MemberStatsTrack = ( enhanceDesignTrackData( @@ -250,6 +331,7 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => // Order and filter active tracks based on wins and submissions return orderBy(filter([ + aiEngineeringTrackStats, dsTrackStats, cpTrackStats, designTrackStats, diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss index 4e5d511d4..e08874d20 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss @@ -5,45 +5,57 @@ width: 100%; .innerWrap { - background-image: linear-gradient(90deg, #05456D, #0A7AC0); + background: #07142F; color: $tc-white; - display: flex; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)) auto; + align-items: center; justify-content: space-between; - padding: $sp-4; - border-radius: 16px; + gap: $sp-3; + padding: $sp-3 $sp-4; + border-radius: 8px; .valueWrap { display: flex; flex-direction: column; - align-items: center; - - &.noPercentile { - flex-direction: row; - align-items: flex-end; - - .name { - margin-left: $sp-2; - } - } + align-items: flex-start; + min-width: 0; .value { - font-size: 26px; + font-size: 24px; font-weight: 500; font-family: $font-barlow-condensed; - line-height: 28px; + line-height: 26px; + white-space: nowrap; } .name { - font-size: 12px; - line-height: 18px; + color: rgba($tc-white, 0.84); + font-size: 10px; + line-height: 14px; } } .link { - font-size: 14px; + color: $tc-white; + font-size: 11px; line-height: 14px; font-weight: $font-weight-medium; font-family: $font-roboto; + white-space: nowrap; + + &:hover { + text-decoration: underline; + } + } + + @include ltelg { + grid-template-columns: repeat(2, minmax(0, 1fr)) auto; + } + + @include ltesm { + grid-template-columns: 1fr; + justify-items: start; } } -} \ No newline at end of file +} diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx index 3ca7e39d0..d57ac30e0 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx @@ -1,8 +1,9 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ import { Dispatch, FC, SetStateAction, useMemo, useState } from 'react' -import classNames from 'classnames' -import { useMemberStats, UserProfile, UserStats } from '~/libs/core' +import { getRatingColor, useMemberStats, UserProfile, UserStats } from '~/libs/core' + +import { numberToFixed } from '../../../lib' import { MemberRatingInfoModal } from './MemberRatingInfoModal' import styles from './MemberRatingCard.module.scss' @@ -11,6 +12,39 @@ interface MemberRatingCardProps { profile: UserProfile } +/** + * Formats percentile values for the compact rating card. + * + * @param {number} percentile - The percentile value returned by the member stats API. + * @returns {string} A display-ready percentage without unnecessary decimal places. + */ +const formatPercentile = (percentile: number): string => ( + Number.isInteger(percentile) ? `${percentile}` : numberToFixed(percentile) +) + +/** + * Returns the audience label shown under the member's percentile. + * + * @param {UserStats | undefined} memberStats - The raw stats payload for the user. + * @returns {string} The track audience label for the rating card. + */ +const getRatingAudienceLabel = (memberStats?: UserStats): string => { + switch (memberStats?.maxRating?.track) { + case 'AI': + case 'AI_ENGINEER': + case 'AI_ENGINEERING': + return 'AI Engineers' + case 'DATA_SCIENCE': + return 'Data Scientists' + case 'DESIGN': + return 'Designers' + case 'DEVELOP': + return 'Developers' + default: + return 'Members' + } +} + const MemberRatingCard: FC = (props: MemberRatingCardProps) => { const memberStats: UserStats | undefined = useMemberStats(props.profile.handle) @@ -45,33 +79,41 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp setIsInfoModalOpen(true) } + const rating: number | undefined = memberStats?.maxRating?.rating + const audienceLabel: string = getRatingAudienceLabel(memberStats) + return memberStats?.maxRating?.rating ? (

-
-

{memberStats?.maxRating?.rating}

+
+

+ {rating} +

Rating

{ maxPercentile ? (
-

- {Number(maxPercentile) - .toFixed(2)} +

+ Top + {' '} + {formatPercentile(maxPercentile)} + %

-

Percentile

+

{audienceLabel}

) : undefined } -
- -
+
{ isInfoModalOpen && ( ) } diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss new file mode 100644 index 000000000..79048e38c --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss @@ -0,0 +1,44 @@ +@import '@libs/ui/styles/includes'; + +.body { + overflow: visible !important; +} + +.content { + display: flex; + flex-direction: column; + gap: $sp-4; +} + +.ratingPanel, +.positionPanel { + display: flex; + flex-direction: column; + gap: 2px; + padding: $sp-4; + border: 1px solid $black-10; + border-radius: 6px; + background: $tc-white; +} + +.panelLabel { + color: $black-80; + font-family: $font-roboto; + font-size: 12px; + font-weight: $font-weight-medium; + line-height: 16px; +} + +.ratingValue { + font-family: $font-barlow-condensed; + font-size: 32px; + font-weight: $font-weight-medium; + line-height: 34px; +} + +.panelMeta { + color: $black-60; + font-family: $font-roboto; + font-size: 12px; + line-height: 16px; +} diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx index 10fa2896c..c59b34760 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx @@ -1,22 +1,93 @@ import { FC } from 'react' +import { getRatingColor } from '~/libs/core' import { BaseModal } from '~/libs/ui' +import { numberToFixed } from '../../../../lib' + +import styles from './MemberRatingInfoModal.module.scss' + interface MemberRatingInfoModalProps { + audienceLabel: string onClose: () => void + percentile?: number + rating?: number +} + +/** + * Returns the Topcoder rating tier name for the provided rating. + * + * @param {number | undefined} rating - The member rating value. + * @returns {string} The tier label displayed in the rating info modal. + */ +const getRatingTierName = (rating?: number): string => { + if (!rating) { + return 'Unrated' + } + + if (rating >= 2200) { + return 'Elite Tier' + } + + if (rating >= 1500) { + return 'Advanced Tier' + } + + if (rating >= 1200) { + return 'Intermediate Tier' + } + + if (rating >= 900) { + return 'Beginner Tier' + } + + return 'Unrated' } const MemberRatingInfoModal: FC = (props: MemberRatingInfoModalProps) => ( -

- Topcoder ratings and percentiles are numerical values that change - depending on how well someone does in coding competitions, - with higher ratings indicating better performance. -

+
+

+ Ratings come from head-to-head competitions and measure demonstrated skill across challenge types. +

+ + {props.rating !== undefined && ( +
+ Overall Rating + + {props.rating} + + {getRatingTierName(props.rating)} +
+ )} + + {props.percentile !== undefined && ( +
+ Position + + Top + {' '} + {numberToFixed(props.percentile, Number.isInteger(props.percentile) ? 0 : 2)} + % + + + of + {' '} + {props.audienceLabel.toLowerCase()} + +
+ )} + +

+ Higher ratings and stronger top-percentile positions indicate better competitive results. +

+
) diff --git a/src/libs/core/lib/profile/user-stats.model.ts b/src/libs/core/lib/profile/user-stats.model.ts index 93cba462c..48e8677df 100644 --- a/src/libs/core/lib/profile/user-stats.model.ts +++ b/src/libs/core/lib/profile/user-stats.model.ts @@ -57,10 +57,24 @@ export type MemberStats = { path?: string } +/** + * Top-level track stats returned by the member stats API. + * + * Some newer tracks are returned as a group with subtracks, while others can be returned + * directly as a stats object. This type keeps those optional payloads narrow enough for + * profile rendering without forcing every API field to be present. + */ +export type MemberStatsGroup = Partial & { + challengePoints?: number + subTracks?: Array +} + export type UserStats = { groupId: number handle: string handleLower: string + challengePoints?: number + CHALLENGE_POINTS?: number challenges: number userId: number wins: number @@ -102,6 +116,9 @@ export type UserStats = { subTracks: Array wins: number } + AI?: MemberStatsGroup + AI_ENGINEER?: MemberStatsGroup + AI_ENGINEERING?: MemberStatsGroup } export type StatsHistory = { From 6241f8ea9c2eac3717b813b3160dd2d0726df379 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 4 Jun 2026 13:48:56 +1000 Subject: [PATCH 02/73] Update to ratings shown on profile page and expose new AI Exponential ratings --- .../MemberStatsBlock.module.scss | 116 ++++- .../MemberStatsBlock/MemberStatsBlock.tsx | 4 +- .../src/hooks/useFetchActiveTracks.spec.tsx | 47 ++ .../src/hooks/useFetchActiveTracks.tsx | 102 +++- .../MemberRatingCard.module.scss | 85 +++- .../MemberRatingCard/MemberRatingCard.tsx | 123 ++--- .../MemberRatingCard.utils.spec.ts | 118 +++++ .../MemberRatingCard.utils.ts | 181 +++++++ .../MemberRatingInfoModal.module.scss | 305 ++++++++++- .../MemberRatingInfoModal.tsx | 477 +++++++++++++++--- src/libs/core/lib/profile/user-stats.model.ts | 33 +- 11 files changed, 1419 insertions(+), 172 deletions(-) create mode 100644 src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts create mode 100644 src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss index f499c89f6..0f146fb2e 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss @@ -60,7 +60,7 @@ border-radius: 8px; background-image: linear-gradient(112deg, #7A2FB0 0%, #0B5D9E 100%); color: $tc-white; - padding: $sp-5 $sp-5 $sp-4; + padding: $sp-8 $sp-9 $sp-7; min-height: 100%; .challengePointsBar + & { @@ -68,26 +68,28 @@ } @include ltelg { - padding: $sp-4; + padding: $sp-5; } :global(.body-large-bold) { + font-size: 26px; + line-height: 32px; text-align: center; } } .sectionTitle { text-align: center; - margin-bottom: $sp-2; + margin-bottom: $sp-5; } .footerNote { - margin-top: $sp-3; + margin-top: $sp-5; color: rgba($tc-white, 0.88); :global(.body-main) { - font-size: 11px; - line-height: 16px; + font-size: 18px; + line-height: 28px; } } @@ -101,10 +103,11 @@ .statsList { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: $sp-2 $sp-4; margin: 0; - @container (max-width: 360px) { + @container (max-width: 520px) { grid-template-columns: 1fr; } @@ -117,10 +120,10 @@ display: flex; align-items: center; justify-content: space-between; - min-height: 41px; - padding: $sp-2 $sp-3; - border: 1px solid rgba($tc-white, 0.12); - border-width: 1px 0 0; + min-height: 68px; + padding: $sp-4 $sp-3 $sp-4 $sp-4; + border: 1px solid rgba($tc-white, 0.28); + border-radius: 8px; transition: 0.15s ease-in; cursor: pointer; color: $tc-white; @@ -133,7 +136,7 @@ .trackDetails { display: flex; align-items: center; - min-width: 84px; + min-width: 136px; > svg { flex: 0 0 auto; @@ -141,44 +144,44 @@ } .rightArrowIcon { - margin-left: $sp-1; - color: rgba($tc-white, 0.65); + margin-left: $sp-4; + color: rgba($tc-white, 0.9); } .winnerIcon { color: #F2C900; - margin-right: $sp-1; + margin-right: $sp-3; } .trackStats { display: flex; flex-direction: column; text-align: right; - min-width: 42px; + min-width: 58px; .count { font-family: $font-barlow-condensed; font-weight: $font-weight-medium; - font-size: 22px; - line-height: 24px; + font-size: 34px; + line-height: 36px; } .label { font-family: $font-roboto; font-weight: $font-weight-medium; - font-size: 10px; - line-height: 12px; + font-size: 14px; + line-height: 18px; color: rgba($tc-white, 0.82); } } .icon { display: block; - @include icon-lg; + @include icon-xxl; background: currentColor; border-radius: 50%; - margin-right: $sp-1; + margin-right: $sp-3; } .trackName { @@ -186,8 +189,73 @@ padding-right: $sp-2; overflow: hidden; font-family: $font-roboto; - font-size: 12px; - line-height: 16px; + font-size: 18px; + line-height: 24px; text-overflow: ellipsis; white-space: nowrap; } + +@container (max-width: 520px) { + .container { + padding: $sp-4; + + :global(.body-large-bold) { + font-size: 18px; + line-height: 24px; + } + } + + .sectionTitle { + margin-bottom: $sp-3; + } + + .footerNote { + :global(.body-main) { + font-size: 12px; + line-height: 18px; + } + } + + .trackListItem { + min-height: 50px; + padding: $sp-2 $sp-3; + } + + .trackDetails { + min-width: 96px; + } + + .trackStats { + min-width: 42px; + + .count { + font-size: 22px; + line-height: 24px; + } + + .label { + font-size: 10px; + line-height: 12px; + } + } + + .trackName { + font-size: 12px; + line-height: 16px; + } + + .icon { + @include icon-lg; + margin-right: $sp-1; + } + + .winnerIcon { + @include icon-lg; + margin-right: $sp-1; + } + + .rightArrowIcon { + @include icon-md; + margin-left: $sp-1; + } +} diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx index 7ec02cdf2..77e691bbb 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx @@ -148,7 +148,7 @@ const MemberStatsBlock: FC = props => { {track.name}
{getTrackDisplayStats(track).indicator === 'winner' && ( - + )} {getTrackDisplayStats(track).indicator === 'rating' && ( = props => {
diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx index d3c232126..9fcd6d085 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx @@ -140,4 +140,51 @@ describe('getActiveTracks', () => { expect(getMemberChallengePoints(memberStats)) .toBe(2847) }) + + it('keeps rated custom data science paths visible as member stats tracks', () => { + const activeTracks: MemberStatsTrack[] = getActiveTracks({ + challenges: 5, + DATA_SCIENCE: { + AI: { + challenges: 3, + rank: { + overallPercentile: 12, + rating: 1422, + }, + wins: 1, + }, + NO_RATING: { + challenges: 2, + rank: {}, + wins: 1, + }, + }, + groupId: 1, + handle: 'winterflame', + handleLower: 'winterflame', + userId: 15391415, + wins: 2, + } as UserStats) + const aiTrack: MemberStatsTrack | undefined = activeTracks.find(track => track.name === 'AI') + + expect(aiTrack) + .toEqual(expect.objectContaining({ + challenges: 3, + isActive: true, + isDSTrack: true, + percentile: 12, + rating: 1422, + wins: 1, + })) + expect(aiTrack?.subTracks) + .toEqual([ + expect.objectContaining({ + name: 'AI', + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', + }), + ]) + expect(activeTracks.map(track => track.name)) + .not.toContain('NO_RATING') + }) }) diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx index c7094ffcf..da7d2f3ad 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx @@ -1,7 +1,14 @@ import { useMemo } from 'react' import { filter, find, get, orderBy } from 'lodash' -import { MemberStats, MemberStatsGroup, SRMStats, useMemberStats, UserStats } from '~/libs/core' +import { + DataScienceRatingPathStats, + MemberStats, + MemberStatsGroup, + SRMStats, + useMemberStats, + UserStats, +} from '~/libs/core' import { calcProportionalAverage } from '../lib/math.utils' @@ -11,6 +18,16 @@ const testingSubTrackNames = new Set([ 'TEST_SUITES', ]) +const nativeDataScienceStatsKeys = new Set([ + 'MARATHON_MATCH', + 'SRM', + 'challenges', + 'mostRecentEventDate', + 'mostRecentEventName', + 'mostRecentSubmission', + 'wins', +]) + /** * The structure of a track for a member. */ @@ -101,6 +118,23 @@ const getFiniteNumber = (value: unknown): number | undefined => ( typeof value === 'number' && Number.isFinite(value) ? value : undefined ) +/** + * Determine whether a DATA_SCIENCE entry is a configured rating path. + * + * Native data science fields include counters and known subtracks; configured + * rating paths are keyed by their path name and should only become profile + * cells after the member has an actual rating. + * + * @param {unknown} statsEntry - A DATA_SCIENCE value from the member stats payload. + * @returns {boolean} Whether the value is a rated custom path stats object. + */ +const isDataScienceRatingPathStats = (statsEntry: unknown): statsEntry is DataScienceRatingPathStats => ( + typeof statsEntry === 'object' + && statsEntry !== null + && !Array.isArray(statsEntry) + && getFiniteNumber((statsEntry as DataScienceRatingPathStats).rank?.rating) !== undefined +) + /** * Returns the AI Engineering stats payload from the known API keys. * @@ -227,6 +261,70 @@ const buildAIEngineeringTrackData = (memberStats?: UserStats): MemberStatsTrack } } +/** + * Builds an active track from a configured DATA_SCIENCE rating path. + * + * The member API stores configured rating paths under `DATA_SCIENCE.`, + * so each path is represented as its own single-subtrack cell while retaining + * `DATA_SCIENCE` as the API track for history and distribution calls. + * + * @param {string} ratingPathName - The configured rating path name, for example `AI`. + * @param {DataScienceRatingPathStats} ratingPathStats - Stats returned for the configured rating path. + * @returns {MemberStatsTrack} Display data for the configured rating path. + */ +const buildDataScienceRatingPathTrackData = ( + ratingPathName: string, + ratingPathStats: DataScienceRatingPathStats, +): MemberStatsTrack => { + const subTrack: MemberStats = { + ...(ratingPathStats as MemberStats), + name: ratingPathName, + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', + } + + return { + challenges: getFiniteNumber(ratingPathStats.challenges) ?? 0, + isActive: true, + isDSTrack: true, + name: ratingPathName, + order: -1, + percentile: getFiniteNumber(ratingPathStats.rank?.overallPercentile) + ?? getFiniteNumber(ratingPathStats.rank?.percentile), + rating: getFiniteNumber(ratingPathStats.rank?.rating), + subTracks: [subTrack], + wins: getFiniteNumber(ratingPathStats.wins) ?? 0, + } +} + +/** + * Builds active tracks for custom DATA_SCIENCE rating paths returned by the member API. + * + * @param {UserStats | undefined} memberStats - The raw stats payload for the user. + * @returns {MemberStatsTrack[]} Rated custom data science paths to display in Member Stats. + */ +const getDataScienceRatingPathTrackData = (memberStats?: UserStats): MemberStatsTrack[] => { + const dataScienceStats = memberStats?.DATA_SCIENCE + + if (!dataScienceStats) { + return [] + } + + return Object.entries(dataScienceStats) + .reduce((ratingPathTracks: MemberStatsTrack[], [ratingPathName, ratingPathStats]) => { + if ( + nativeDataScienceStatsKeys.has(ratingPathName) + || !isDataScienceRatingPathStats(ratingPathStats) + ) { + return ratingPathTracks + } + + ratingPathTracks.push(buildDataScienceRatingPathTrackData(ratingPathName, ratingPathStats)) + + return ratingPathTracks + }, []) +} + /** * Custom hook to fetch active tracks for a user, sorted by wins & submissions. * @@ -298,6 +396,7 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => const dsSubTracks: MemberStats[] = [ dataScienceSubTracks.MARATHON_MATCH, ].filter(d => d?.challenges > 0) as MemberStats[] + const dataScienceRatingPathTrackStats: MemberStatsTrack[] = getDataScienceRatingPathTrackData(memberStats) const dsTrackStats: MemberStatsTrack = { challenges: dataScienceSubTracks.MARATHON_MATCH?.challenges ?? 0, @@ -337,6 +436,7 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => designTrackStats, developTrackStats, testingTrackStats, + ...dataScienceRatingPathTrackStats, ], { isActive: true }), ['order', 'wins', 'submissions'], ['desc', 'desc', 'desc']) } diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss index e08874d20..dfea59f85 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss @@ -8,35 +8,75 @@ background: #07142F; color: $tc-white; display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)) auto; - align-items: center; - justify-content: space-between; - gap: $sp-3; - padding: $sp-3 $sp-4; + grid-template-columns: 52px 104px auto; + align-items: start; + column-gap: $sp-4; + justify-content: start; + min-height: 70px; + padding: $sp-4 $sp-5 $sp-3; border-radius: 8px; + width: 286px; + max-width: 100%; .valueWrap { + appearance: none; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; display: flex; flex-direction: column; align-items: flex-start; min-width: 0; + padding: 0; + text-align: left; + + &:hover { + .name { + text-decoration: underline; + } + } .value { - font-size: 24px; + font-size: 26px; font-weight: 500; font-family: $font-barlow-condensed; - line-height: 26px; + line-height: 28px; white-space: nowrap; } .name { color: rgba($tc-white, 0.84); - font-size: 10px; + font-size: 12px; line-height: 14px; } + + .percentileValue { + background: rgba($tc-white, 0.06); + border-radius: 2px; + font-family: $font-roboto; + font-size: 13px; + font-weight: $font-weight-bold; + line-height: 16px; + padding: 2px $sp-2; + } + } + + .percentileWrap { + margin-top: 0; + + .name { + margin-left: $sp-2; + margin-top: calc(#{$sp-2} + 2px); + white-space: nowrap; + } } .link { + grid-column: -2 / -1; + align-self: start; + justify-self: end; + margin-top: $sp-1; color: $tc-white; font-size: 11px; line-height: 14px; @@ -50,12 +90,39 @@ } @include ltelg { - grid-template-columns: repeat(2, minmax(0, 1fr)) auto; + grid-template-columns: 52px 104px auto; } @include ltesm { grid-template-columns: 1fr; + width: 100%; justify-items: start; + + .link { + grid-column: auto; + justify-self: start; + margin-top: 0; + } } } } + +.ratingTooltip { + --rt-opacity: 1; + + background-color: $black-100 !important; + border-radius: 4px !important; + font-size: 14px !important; + line-height: 21px !important; + opacity: 1 !important; + padding: $sp-2 $sp-3 !important; + + :global(.react-tooltip-arrow) { + background-color: $black-100 !important; + } +} + +.tooltipContent { + display: block; + white-space: nowrap; +} diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx index d57ac30e0..d55909d59 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx @@ -1,10 +1,23 @@ -/* eslint-disable jsx-a11y/anchor-is-valid */ import { Dispatch, FC, SetStateAction, useMemo, useState } from 'react' - -import { getRatingColor, useMemberStats, UserProfile, UserStats } from '~/libs/core' +import classNames from 'classnames' + +import { + getRatingColor, + useMemberStats, + UserProfile, + UserStats, + UserStatsDistributionResponse, + useStatsDistribution, +} from '~/libs/core' +import { Tooltip } from '~/libs/ui' import { numberToFixed } from '../../../lib' +import { + calculateTopPercentileFromDistribution, + getRatingAudienceLabel, + getRatingDistributionQuery, +} from './MemberRatingCard.utils' import { MemberRatingInfoModal } from './MemberRatingInfoModal' import styles from './MemberRatingCard.module.scss' @@ -15,61 +28,30 @@ interface MemberRatingCardProps { /** * Formats percentile values for the compact rating card. * - * @param {number} percentile - The percentile value returned by the member stats API. + * @param {number} percentile - The percentile value calculated from the rating distribution. * @returns {string} A display-ready percentage without unnecessary decimal places. */ const formatPercentile = (percentile: number): string => ( - Number.isInteger(percentile) ? `${percentile}` : numberToFixed(percentile) + numberToFixed(percentile, 0) ) -/** - * Returns the audience label shown under the member's percentile. - * - * @param {UserStats | undefined} memberStats - The raw stats payload for the user. - * @returns {string} The track audience label for the rating card. - */ -const getRatingAudienceLabel = (memberStats?: UserStats): string => { - switch (memberStats?.maxRating?.track) { - case 'AI': - case 'AI_ENGINEER': - case 'AI_ENGINEERING': - return 'AI Engineers' - case 'DATA_SCIENCE': - return 'Data Scientists' - case 'DESIGN': - return 'Designers' - case 'DEVELOP': - return 'Developers' - default: - return 'Members' - } -} - const MemberRatingCard: FC = (props: MemberRatingCardProps) => { const memberStats: UserStats | undefined = useMemberStats(props.profile.handle) const [isInfoModalOpen, setIsInfoModalOpen]: [boolean, Dispatch>] = useState(false) - const maxPercentile: number = useMemo(() => { - let memberPercentile: number = 0 - if (memberStats?.DATA_SCIENCE) { - memberPercentile = memberStats.DATA_SCIENCE.MARATHON_MATCH?.rank?.percentile || 0 - if ((memberStats.DATA_SCIENCE.SRM?.rank?.percentile || 0) > memberPercentile) { - memberPercentile = memberStats.DATA_SCIENCE.SRM.rank?.percentile || 0 - } - } + const ratingDistributionQuery = useMemo(() => getRatingDistributionQuery(memberStats), [memberStats]) - if (memberStats?.DEVELOP) { - memberStats.DEVELOP.subTracks.forEach((subTrack: any) => { - const subPercentile = subTrack.rank.percentile || subTrack.rank.overallPercentile || 0 - if (subPercentile > memberPercentile) { - memberPercentile = subPercentile - } - }) - } + const ratingDistribution: UserStatsDistributionResponse | undefined = useStatsDistribution(ratingDistributionQuery) - return memberPercentile - }, [memberStats]) + const rating: number | undefined = memberStats?.maxRating?.rating + const maxPercentile: number | undefined = useMemo(() => ( + calculateTopPercentileFromDistribution(ratingDistribution?.distribution, rating) + ), [rating, ratingDistribution]) + const audienceLabel: string = getRatingAudienceLabel(memberStats) + const percentileLabel: string | undefined = maxPercentile + ? `Top ${formatPercentile(maxPercentile)}%` + : undefined function handleInfoModalClose(): void { setIsInfoModalOpen(false) @@ -79,29 +61,46 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp setIsInfoModalOpen(true) } - const rating: number | undefined = memberStats?.maxRating?.rating - const audienceLabel: string = getRatingAudienceLabel(memberStats) - return memberStats?.maxRating?.rating ? (
-
+
+ { - maxPercentile ? ( -
-

- Top - {' '} - {formatPercentile(maxPercentile)} - % -

-

{audienceLabel}

-
+ percentileLabel ? ( + + {percentileLabel} + {' '} + of +
+ 2M + {' '} + {audienceLabel.toLowerCase()} + + )} + place='top' + > + +
) : undefined } @@ -112,8 +111,10 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp ) } diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts new file mode 100644 index 000000000..98b186449 --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts @@ -0,0 +1,118 @@ +import type { UserStats, UserStatsDistributionResponse } from '~/libs/core' + +import { + calculateTopPercentileFromDistribution, + getRatingAudienceLabel, + getRatingDistributionQuery, +} from './MemberRatingCard.utils' + +describe('calculateTopPercentileFromDistribution', () => { + const distribution: UserStatsDistributionResponse['distribution'] = { + ratingRange0To899: 100, + ratingRange900To1199: 200, + ratingRange1200To1499: 300, + ratingRange1500To2199: 400, + } + + it('counts all members at or above the start of the matching rating range', () => { + expect(calculateTopPercentileFromDistribution(distribution, 1500)) + .toBe(40) + }) + + it('interpolates the matching range based on the member rating', () => { + expect(calculateTopPercentileFromDistribution(distribution, 1350)) + .toBeCloseTo(55) + }) + + it('returns undefined when the rating or distribution cannot be used', () => { + expect(calculateTopPercentileFromDistribution(distribution, undefined)) + .toBeUndefined() + expect(calculateTopPercentileFromDistribution({}, 1500)) + .toBeUndefined() + }) +}) + +describe('getRatingAudienceLabel', () => { + it('maps top rating tracks to audience labels', () => { + expect(getRatingAudienceLabel({ + maxRating: { + rating: 1700, + ratingColor: 'yellow', + subTrack: 'Challenge', + track: 'DESIGN', + }, + } as UserStats)) + .toBe('Designers') + expect(getRatingAudienceLabel({ + maxRating: { + rating: 1700, + ratingColor: 'yellow', + subTrack: 'Challenge', + track: 'DEVELOP', + }, + } as UserStats)) + .toBe('Developers') + expect(getRatingAudienceLabel({ + maxRating: { + rating: 1700, + ratingColor: 'yellow', + subTrack: 'MARATHON_MATCH', + track: 'DATA_SCIENCE', + }, + } as UserStats)) + .toBe('Data Scientists') + expect(getRatingAudienceLabel({ + maxRating: { + rating: 1700, + ratingColor: 'yellow', + subTrack: 'Challenge', + track: 'QA', + }, + } as UserStats)) + .toBe('QA Professionals') + }) + + it('uses the QA label for legacy testing subtracks under development', () => { + expect(getRatingAudienceLabel({ + maxRating: { + rating: 1400, + ratingColor: 'blue', + subTrack: 'BUG_HUNT', + track: 'DEVELOP', + }, + } as UserStats)) + .toBe('QA Professionals') + }) +}) + +describe('getRatingDistributionQuery', () => { + it('uses the highest rating track and subtrack for distribution lookup', () => { + expect(getRatingDistributionQuery({ + maxRating: { + rating: 1800, + ratingColor: 'yellow', + subTrack: 'Challenge', + track: 'DEVELOP', + }, + } as UserStats)) + .toEqual({ + subTrack: 'Challenge', + track: 'DEVELOP', + }) + }) + + it('maps AI engineering ratings to the configured data science distribution', () => { + expect(getRatingDistributionQuery({ + maxRating: { + rating: 101, + ratingColor: 'gray', + subTrack: 'AI', + track: 'AI_ENGINEERING', + }, + } as UserStats)) + .toEqual({ + subTrack: 'AI', + track: 'DATA_SCIENCE', + }) + }) +}) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts new file mode 100644 index 000000000..599c219a3 --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts @@ -0,0 +1,181 @@ +import { UserStats, UserStatsDistributionResponse } from '~/libs/core' + +interface RatingDistributionQuery { + subTrack: string + track: string +} + +interface RatingDistributionRange { + end: number + start: number + value: number +} + +const aiEngineeringTrackNames: Set = new Set([ + 'AI', + 'AI_ENGINEER', + 'AI_ENGINEERING', +]) + +const testingSubTrackNames: Set = new Set([ + 'BUG_HUNT', + 'TEST_SCENARIOS', + 'TEST_SUITES', +]) + +const ratingAudienceLabels: {[key: string]: string} = { + DATA_SCIENCE: 'Data Scientists', + DATASCIENCE: 'Data Scientists', + DESIGN: 'Designers', + DEV: 'Developers', + DEVELOP: 'Developers', + DEVELOPMENT: 'Developers', + QA: 'QA Professionals', + QUALITY_ASSURANCE: 'QA Professionals', + TESTING: 'QA Professionals', +} + +/** + * Returns a normalized track or subtrack token for lookup. + * + * @param {string | undefined} value - Raw track or subtrack value from member stats. + * @returns {string} Uppercase token with spaces and hyphens converted to underscores. + */ +const normalizeTrackToken = (value?: string): string => ( + value?.trim() + .toUpperCase() + .replace(/[\s-]+/g, '_') ?? '' +) + +/** + * Returns a finite number from unknown API data when the value can be used in math. + * + * @param {unknown} value - A raw API value that may or may not be numeric. + * @returns {number | undefined} The numeric value when it is finite, otherwise undefined. + */ +const getFiniteNumber = (value: unknown): number | undefined => ( + typeof value === 'number' && Number.isFinite(value) ? value : undefined +) + +/** + * Parses the stats distribution API response into sorted rating ranges. + * + * @param {UserStatsDistributionResponse['distribution'] | undefined} distribution - Raw distribution buckets. + * @returns {RatingDistributionRange[]} Sorted numeric ranges with member counts. + */ +const getDistributionRanges = ( + distribution?: UserStatsDistributionResponse['distribution'], +): RatingDistributionRange[] => ( + Object.entries(distribution ?? {}) + .map(([key, value]: [string, number]) => { + const match: RegExpMatchArray | null = key.match(/ratingRange(\d+)To(\d+)/) + + return { + end: match ? parseInt(match[2], 10) : Number.NaN, + start: match ? parseInt(match[1], 10) : Number.NaN, + value, + } + }) + .filter((range: RatingDistributionRange) => ( + Number.isFinite(range.start) + && Number.isFinite(range.end) + && Number.isFinite(range.value) + && range.value > 0 + )) + .sort((rangeA: RatingDistributionRange, rangeB: RatingDistributionRange) => rangeA.start - rangeB.start) +) + +/** + * Calculates the visible "Top X%" value from the rating distribution. + * + * The distribution only gives bucket counts, so when a rating falls inside a + * bucket this assumes members are evenly distributed across that bucket and + * counts the proportional share at or above the member rating. + * + * @param {UserStatsDistributionResponse['distribution'] | undefined} distribution - Raw rating distribution buckets. + * @param {number | undefined} memberRating - The member's maximum rating in the same track/subtrack. + * @returns {number | undefined} Top percentage when the distribution and rating are available. + */ +export const calculateTopPercentileFromDistribution = ( + distribution: UserStatsDistributionResponse['distribution'] | undefined, + memberRating: number | undefined, +): number | undefined => { + const rating = getFiniteNumber(memberRating) + const ranges = getDistributionRanges(distribution) + const totalMembers = ranges.reduce(( + total: number, + range: RatingDistributionRange, + ) => total + range.value, 0) + + if (rating === undefined || totalMembers <= 0) { + return undefined + } + + const membersAtOrAboveRating = ranges.reduce(( + total: number, + range: RatingDistributionRange, + ) => { + if (rating <= range.start) { + return total + range.value + } + + if (rating > range.end) { + return total + } + + const rangeSize = range.end - range.start + 1 + const ratingAndAboveSize = range.end - rating + 1 + + return total + (range.value * (ratingAndAboveSize / rangeSize)) + }, 0) + + if (membersAtOrAboveRating <= 0) { + return undefined + } + + return (membersAtOrAboveRating / totalMembers) * 100 +} + +/** + * Returns the distribution query that corresponds to the user's maximum rating track. + * + * @param {UserStats | undefined} memberStats - The raw stats payload for the user. + * @returns {RatingDistributionQuery | undefined} The API query for rating distribution data. + */ +export const getRatingDistributionQuery = (memberStats?: UserStats): RatingDistributionQuery | undefined => { + const maxRating = memberStats?.maxRating + + if (!maxRating?.track || !maxRating?.subTrack) { + return undefined + } + + if (aiEngineeringTrackNames.has(normalizeTrackToken(maxRating.track))) { + return { + subTrack: 'AI', + track: 'DATA_SCIENCE', + } + } + + return { + subTrack: maxRating.subTrack, + track: maxRating.track, + } +} + +/** + * Returns the audience label shown after the member's top percentile. + * + * @param {UserStats | undefined} memberStats - The raw stats payload for the user. + * @returns {string} The broad track audience label for the rating card and modal. + */ +export const getRatingAudienceLabel = (memberStats?: UserStats): string => { + const maxRating = memberStats?.maxRating + const normalizedTrack = normalizeTrackToken(maxRating?.track) + const normalizedSubTrack = normalizeTrackToken(maxRating?.subTrack) + + if (testingSubTrackNames.has(normalizedSubTrack)) { + return ratingAudienceLabels.QA + } + + return ratingAudienceLabels[normalizedTrack] ?? 'Members' +} diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss index 79048e38c..d1c6e5196 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss @@ -1,27 +1,101 @@ @import '@libs/ui/styles/includes'; +.modal { + width: 626px !important; + min-width: 0 !important; + max-width: calc(100vw - #{$sp-8}) !important; + padding: $sp-6 $sp-7 !important; + + :global(.react-responsive-modal-closeButton) { + right: $sp-5; + top: $sp-5; + + svg { + fill: $turq-160 !important; + } + } + + @include ltemd { + width: calc(100vw - #{$sp-4}) !important; + max-width: calc(100vw - #{$sp-4}) !important; + padding: $sp-5 $sp-4 !important; + } +} + .body { + margin: 0 !important; overflow: visible !important; + padding: 0 !important; } .content { display: flex; flex-direction: column; gap: $sp-4; + min-width: 0; +} + +.title { + color: $black-100; + font-family: $font-roboto; + font-size: 16px; + font-weight: $font-weight-bold; + line-height: 24px; + margin: 0; + padding-right: $sp-8; +} + +.divider { + border: 0; + border-top: 1px solid $black-10; + margin: $sp-4 0 $sp-1; +} + +.description { + color: $black-100; + font-family: $font-roboto; + font-size: 14px; + line-height: 21px; + margin: 0; } -.ratingPanel, -.positionPanel { +.summaryPanel { + align-items: stretch; + border: 1px solid $black-10; + border-radius: 6px; + display: grid; + grid-template-columns: minmax(0, 1fr) 126px minmax(0, 1fr); + min-height: 96px; + overflow: hidden; + + @include ltesm { + grid-template-columns: 1fr; + } +} + +.summaryMetric { display: flex; flex-direction: column; gap: 2px; - padding: $sp-4; - border: 1px solid $black-10; - border-radius: 6px; - background: $tc-white; + justify-content: center; + min-width: 0; + padding: $sp-3 $sp-6; + + @include ltesm { + padding: $sp-4; + } +} + +.positionMetric { + border-left: 1px solid $black-10; + + @include ltesm { + border-left: 0; + border-top: 1px solid $black-10; + } } -.panelLabel { +.summaryLabel { color: $black-80; font-family: $font-roboto; font-size: 12px; @@ -34,11 +108,226 @@ font-size: 32px; font-weight: $font-weight-medium; line-height: 34px; + white-space: nowrap; } -.panelMeta { +.positionValue { + font-family: $font-barlow-condensed; + font-size: 28px; + font-weight: $font-weight-medium; + line-height: 32px; + text-transform: uppercase; + white-space: nowrap; +} + +.summaryMeta { color: $black-60; font-family: $font-roboto; font-size: 12px; line-height: 16px; } + +.tierPyramid { + align-items: center; + border-left: 1px solid $black-10; + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + padding: $sp-2 0; + + @include ltesm { + border-left: 0; + border-top: 1px solid $black-10; + padding: $sp-4 0; + } +} + +.tierPyramidSvg { + display: block; + height: 69px; + width: 76px; +} + +.sectionTitle { + color: $black-100; + font-family: $font-roboto; + font-size: 14px; + font-weight: $font-weight-bold; + line-height: 20px; + margin: $sp-1 0 0; +} + +.chart { + background: $black-5; + border-radius: 6px; + height: 228px; + min-width: 0; + overflow: hidden; + position: relative; +} + +.bars { + align-items: flex-end; + bottom: $sp-7; + display: flex; + gap: 2px; + height: 176px; + left: $sp-5; + position: absolute; + right: $sp-5; +} + +.bar { + border-radius: 1px 1px 0 0; + flex: 1 1 0; + min-width: 2px; +} + +.memberMarker { + align-items: center; + bottom: $sp-7; + display: flex; + flex-direction: column; + position: absolute; + top: $sp-12; + transform: translateX(-50%); + width: 0; + z-index: 2; + + &::after { + background: #F2C900; + bottom: 0; + content: ''; + display: block; + position: absolute; + top: $sp-6; + width: 1px; + } +} + +.markerBadge { + align-items: center; + display: flex; + gap: $sp-2; + position: relative; + transform: translateX(34px); + z-index: 1; +} + +.markerAvatar { + align-items: center; + background: $tc-white; + border: 2px solid $tc-white; + border-radius: 50%; + box-shadow: 0 1px 4px rgba($tc-black, 0.2); + display: flex; + height: 32px; + justify-content: center; + overflow: hidden; + width: 32px; + + img { + display: block; + height: 100%; + object-fit: cover; + width: 100%; + } +} + +.markerInitial { + align-items: center; + background: $black-20; + color: $black-80; + display: flex; + font-family: $font-roboto; + font-size: 12px; + font-weight: $font-weight-bold; + height: 100%; + justify-content: center; + width: 100%; +} + +.markerRating { + font-family: $font-roboto; + font-size: 13px; + font-weight: $font-weight-bold; + line-height: 18px; +} + +.axisLabels { + bottom: $sp-3; + color: $black-80; + font-family: $font-roboto; + font-size: 9px; + left: $sp-5; + line-height: 12px; + position: absolute; + right: $sp-5; + + span { + position: absolute; + transform: translateX(-50%); + white-space: nowrap; + } +} + +.emptyDistribution { + align-items: center; + color: $black-80; + display: flex; + font-family: $font-roboto; + font-size: 14px; + height: 100%; + justify-content: center; + margin: 0; +} + +.legend { + display: grid; + gap: $sp-3; + grid-template-columns: repeat(5, minmax(0, 1fr)); + + @include ltesm { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.legendCard { + border: 1px solid $black-10; + border-radius: 6px; + display: flex; + flex-direction: column; + min-height: 50px; + min-width: 0; + padding: $sp-2 $sp-3; +} + +.legendCardActive { + background: var(--tier-highlight-color); + border-color: var(--tier-color); +} + +.legendSwatch { + background: var(--tier-color); + border-radius: 3px; + display: block; + height: 4px; + margin-bottom: $sp-2; + width: 18px; +} + +.legendRange { + color: $black-100; + font-family: $font-roboto; + font-size: 11px; + font-weight: $font-weight-bold; + line-height: 14px; +} + +.legendLabel { + color: $black-80; + font-family: $font-roboto; + font-size: 10px; + line-height: 14px; +} diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx index c59b34760..c5a7e2e79 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx @@ -1,6 +1,7 @@ -import { FC } from 'react' +import { CSSProperties, FC, useMemo } from 'react' +import classNames from 'classnames' -import { getRatingColor } from '~/libs/core' +import { getRatingColor, UserProfile, UserStatsDistributionResponse } from '~/libs/core' import { BaseModal } from '~/libs/ui' import { numberToFixed } from '../../../../lib' @@ -11,84 +12,444 @@ interface MemberRatingInfoModalProps { audienceLabel: string onClose: () => void percentile?: number + profile: UserProfile rating?: number + ratingDistribution?: UserStatsDistributionResponse } +interface RatingDistributionRange { + end: number + key: string + start: number + value: number +} + +interface RatingTier { + color: string + end?: number + highlightColor: string + id: string + label: string + rangeLabel: string + start: number + tierLabel: string +} + +interface PyramidTierShape { + points: string + tierId: string +} + +const ratingTiers: RatingTier[] = [{ + color: '#555555', + end: 899, + highlightColor: '#F4F4F4', + id: 'beginner', + label: 'Beginner', + rangeLabel: '0-899', + start: 0, + tierLabel: 'Beginner Tier', +}, { + color: '#2D7E2D', + end: 1199, + highlightColor: '#E8F5E8', + id: 'intermediate', + label: 'Intermediate', + rangeLabel: '900-1199', + start: 900, + tierLabel: 'Intermediate Tier', +}, { + color: '#616BD5', + end: 1499, + highlightColor: '#EEF0FF', + id: 'skilled', + label: 'Skilled', + rangeLabel: '1200-1499', + start: 1200, + tierLabel: 'Skilled Tier', +}, { + color: '#F2C900', + end: 2199, + highlightColor: '#FFFBD0', + id: 'advanced', + label: 'Advanced', + rangeLabel: '1500-2199', + start: 1500, + tierLabel: 'Advanced Tier', +}, { + color: '#EF3A3A', + highlightColor: '#FFF0F0', + id: 'elite', + label: 'Elite', + rangeLabel: '2200+', + start: 2200, + tierLabel: 'Elite Tier', +}] + +const chartAxisLabels: Array<{ label: string, value: number }> = [{ + label: '0', + value: 0, +}, { + label: '900', + value: 900, +}, { + label: '1200', + value: 1200, +}, { + label: '1500', + value: 1500, +}, { + label: '2200+', + value: 2200, +}] + +const pyramidTierShapes: PyramidTierShape[] = [{ + points: '50 0 60 16 40 16', + tierId: 'elite', +}, { + points: '36 21 64 21 70 34 30 34', + tierId: 'advanced', +}, { + points: '29 39 71 39 78 52 22 52', + tierId: 'skilled', +}, { + points: '20 57 80 57 88 70 12 70', + tierId: 'intermediate', +}, { + points: '12 75 88 75 100 91 0 91', + tierId: 'beginner', +}] + +/** + * Formats percentile values for the rating comparison modal. + * + * Used by MemberRatingInfoModal to keep the top percentile text compact. + * + * @param {number | undefined} percentile - The percentile value calculated from the rating distribution. + * @returns {string} A display-ready percentage or `--` when the percentile is unavailable. + */ +const formatPercentile = (percentile?: number): string => ( + percentile === undefined || percentile === 0 + ? '--' + : numberToFixed(percentile, 0) +) + +/** + * Returns the Topcoder rating tier metadata for a rating. + * + * Used by MemberRatingInfoModal to color the summary, histogram, pyramid, and legend. + * + * @param {number | undefined} rating - The member rating value or a rating range start. + * @returns {RatingTier} The tier metadata that matches the rating. + */ +const getRatingTier = (rating?: number): RatingTier => ( + ratingTiers.find((tier: RatingTier) => ( + rating !== undefined + && rating >= tier.start + && (tier.end === undefined || rating <= tier.end) + )) ?? ratingTiers[0] +) + /** * Returns the Topcoder rating tier name for the provided rating. * + * Used by MemberRatingInfoModal in the overall rating summary. + * * @param {number | undefined} rating - The member rating value. * @returns {string} The tier label displayed in the rating info modal. */ -const getRatingTierName = (rating?: number): string => { - if (!rating) { - return 'Unrated' - } +const getRatingTierName = (rating?: number): string => ( + rating === undefined ? 'Unrated' : getRatingTier(rating).tierLabel +) - if (rating >= 2200) { - return 'Elite Tier' - } +/** + * Parses the API distribution payload into ordered rating ranges. + * + * Used by MemberRatingInfoModal to render custom histogram bars from the same + * data used by the detailed member stats chart. + * + * @param {UserStatsDistributionResponse['distribution'] | undefined} distribution - Raw API distribution data. + * @returns {RatingDistributionRange[]} Sorted histogram ranges with numeric starts, ends, and counts. + */ +const getDistributionRanges = ( + distribution?: UserStatsDistributionResponse['distribution'], +): RatingDistributionRange[] => ( + Object.entries(distribution ?? {}) + .map(([key, value]: [string, number]) => { + const match: RegExpMatchArray | null = key.match(/ratingRange(\d+)To(\d+)/) - if (rating >= 1500) { - return 'Advanced Tier' + return { + end: match ? parseInt(match[2], 10) : Number.NaN, + key, + start: match ? parseInt(match[1], 10) : Number.NaN, + value, + } + }) + .filter((range: RatingDistributionRange) => ( + Number.isFinite(range.start) + && Number.isFinite(range.end) + && Number.isFinite(range.value) + )) + .sort((rangeA: RatingDistributionRange, rangeB: RatingDistributionRange) => rangeA.start - rangeB.start) +) + +/** + * Returns the chart end rating used for marker and axis positioning. + * + * Used by MemberRatingInfoModal to align labels with the rendered distribution range. + * + * @param {RatingDistributionRange[]} ranges - Parsed rating distribution ranges. + * @returns {number} The maximum rating represented by the chart. + */ +const getChartEndRating = (ranges: RatingDistributionRange[]): number => { + if (ranges.length === 0) { + return 3999 } - if (rating >= 1200) { - return 'Intermediate Tier' + return ranges[ranges.length - 1].end +} + +/** + * Calculates a horizontal chart position for a rating value. + * + * Used by MemberRatingInfoModal for the member marker and static x-axis labels. + * + * @param {number} rating - The rating value to position. + * @param {RatingDistributionRange[]} ranges - Parsed rating distribution ranges. + * @returns {number} A clamped percentage from 0 to 100. + */ +const getChartPosition = (rating: number, ranges: RatingDistributionRange[]): number => { + const chartStart = ranges[0]?.start ?? 0 + const chartEnd = getChartEndRating(ranges) + const chartSpan = chartEnd - chartStart + + if (chartSpan <= 0) { + return 0 } - if (rating >= 900) { - return 'Beginner Tier' + const clampedRating = Math.max(chartStart, Math.min(rating, chartEnd)) + + return ((clampedRating - chartStart) / chartSpan) * 100 +} + +/** + * Calculates a bar height for a histogram count. + * + * Used by MemberRatingInfoModal to preserve visible bars for low non-zero ranges. + * + * @param {number} value - Number of members in the rating range. + * @param {number} maxValue - Highest count in the distribution. + * @returns {number} A percentage height for CSS rendering. + */ +const getBarHeight = (value: number, maxValue: number): number => { + if (value <= 0 || maxValue <= 0) { + return 0 } - return 'Unrated' + return Math.max(4, Math.round((value / maxValue) * 100)) } -const MemberRatingInfoModal: FC = (props: MemberRatingInfoModalProps) => ( - -
-

- Ratings come from head-to-head competitions and measure demonstrated skill across challenge types. -

- - {props.rating !== undefined && ( -
- Overall Rating - - {props.rating} - - {getRatingTierName(props.rating)} -
+/** + * Returns the display name used in the modal title and section copy. + * + * Used by MemberRatingInfoModal to prefer first name, then handle, then a generic label. + * + * @param {UserProfile} profile - The profile currently being viewed. + * @returns {string} The safest member display label for comparison copy. + */ +const getMemberDisplayName = (profile: UserProfile): string => ( + profile.firstName || profile.handle || 'This member' +) + +/** + * Returns a fallback avatar initial for profiles without a photo. + * + * Used by MemberRatingInfoModal to keep the marker badge visible when no photo URL exists. + * + * @param {UserProfile} profile - The profile currently being viewed. + * @returns {string} A one-character fallback initial. + */ +const getProfileInitial = (profile: UserProfile): string => ( + (profile.firstName || profile.handle || '?') + .charAt(0) + .toUpperCase() +) + +const MemberRatingInfoModal: FC = (props: MemberRatingInfoModalProps) => { + const displayName: string = getMemberDisplayName(props.profile) + const titleDisplayName: string = displayName + .toUpperCase() + const selectedTier: RatingTier = getRatingTier(props.rating) + const distributionRanges: RatingDistributionRange[] = useMemo(() => ( + getDistributionRanges(props.ratingDistribution?.distribution) + ), [props.ratingDistribution]) + const maxDistributionValue: number = Math.max( + 1, + ...distributionRanges.map((range: RatingDistributionRange) => range.value), + ) + const markerPosition: number = props.rating !== undefined + ? getChartPosition(props.rating, distributionRanges) + : 0 + const percentileLabel: string = formatPercentile(props.percentile) + + return ( + + HOW + {' '} + {titleDisplayName} + {' '} + COMPARES TO 2M+ MEMBERS + )} + size='lg' + > +
+
- {props.percentile !== undefined && ( -
- Position - - Top - {' '} - {numberToFixed(props.percentile, Number.isInteger(props.percentile) ? 0 : 2)} - % - - - of - {' '} - {props.audienceLabel.toLowerCase()} - +

+ Ratings come from head-to-head competitions and measure demonstrated skill across all + challenge types. +

+ +
+
+ Overall Rating + + {props.rating ?? '--'} + + {getRatingTierName(props.rating)} +
+ + + +
+ Position + + TOP + {' '} + {percentileLabel} + {percentileLabel === '--' ? '' : '%'} + + {props.audienceLabel} +
- )} -

- Higher ratings and stronger top-percentile positions indicate better competitive results. -

-
- -) +

+ Where + {' '} + {displayName} + {' '} + ranks in the distribution +

+ +
+ {distributionRanges.length > 0 ? ( + <> +
+ {distributionRanges.map((range: RatingDistributionRange) => ( + + ))} +
+ + {props.rating !== undefined && ( +
+
+ + {props.profile.photoURL ? ( + {`${displayName} + ) : ( + + {getProfileInitial(props.profile)} + + )} + + + {props.rating} + +
+
+ )} + +
+ {chartAxisLabels.map((axisLabel: { label: string, value: number }) => ( + + {axisLabel.label} + + ))} +
+ + ) : ( +

Rating distribution is loading.

+ )} +
+ +
+ {ratingTiers.map((tier: RatingTier) => { + const isActive = tier.id === selectedTier.id + + return ( +
+ + {tier.rangeLabel} + {tier.label} +
+ ) + })} +
+
+
+ ) +} export default MemberRatingInfoModal diff --git a/src/libs/core/lib/profile/user-stats.model.ts b/src/libs/core/lib/profile/user-stats.model.ts index 48e8677df..48b838715 100644 --- a/src/libs/core/lib/profile/user-stats.model.ts +++ b/src/libs/core/lib/profile/user-stats.model.ts @@ -69,6 +69,29 @@ export type MemberStatsGroup = Partial & { subTracks?: Array } +/** + * Sparse stats object returned for configured DATA_SCIENCE rating paths. + * + * Custom rating paths such as `AI` are stored under their configured path name + * and may only include counters plus the rank fields currently available for + * that path. + */ +export type DataScienceRatingPathStats = Partial> & { + name?: string + rank?: Partial +} + +export type DataScienceStats = { + MARATHON_MATCH?: MemberStats + SRM?: SRMStats + challenges?: number + mostRecentEventDate?: number + mostRecentEventName?: string + mostRecentSubmission?: number + wins?: number + [ratingPath: string]: DataScienceRatingPathStats | MemberStats | SRMStats | number | string | undefined +} + export type UserStats = { groupId: number handle: string @@ -93,15 +116,7 @@ export type UserStats = { projects: number reposts: number } - DATA_SCIENCE?: { - MARATHON_MATCH: MemberStats - SRM: SRMStats - challenges: number - mostRecentEventDate: number - mostRecentEventName: string - mostRecentSubmission: number - wins: number - } + DATA_SCIENCE?: DataScienceStats DEVELOP?: { challenges: number mostRecentEventDate: number From 15b41e68187432d12bafffa10a468524d667fc12 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 4 Jun 2026 13:50:34 +1000 Subject: [PATCH 03/73] Deploy branch --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index d52fc3db0..cbfbc3113 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -228,7 +228,7 @@ workflows: branches: only: - dev - - hide_ba_details + - ai-ratings tags: only: /^dev-.*/ From c1ac65019e10d3c7338003611a3ccb0b2b2beb98 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 4 Jun 2026 14:41:45 +1000 Subject: [PATCH 04/73] Points tracking, processing, and display on profile. --- .../MemberChallengePointsModal.module.scss | 129 ++++++++++ .../MemberChallengePointsModal.tsx | 110 ++++++++ .../MemberStatsBlock.module.scss | 12 + .../MemberStatsBlock/MemberStatsBlock.tsx | 234 ++++++++++++------ .../MemberRatingInfoModal.module.scss | 16 +- .../MemberRatingInfoModal.tsx | 4 +- .../skills/MemberSkillsInfo.module.scss | 41 +++ .../skills/MemberSkillsInfo.tsx | 63 ++++- .../tc-achievements/MemberTCAchievements.tsx | 9 +- .../core/lib/profile/user-profile.model.ts | 15 ++ 10 files changed, 543 insertions(+), 90 deletions(-) create mode 100644 src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.module.scss create mode 100644 src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.tsx diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.module.scss b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.module.scss new file mode 100644 index 000000000..7d471efac --- /dev/null +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.module.scss @@ -0,0 +1,129 @@ +@import "@libs/ui/styles/includes"; + +.modal { + width: 520px !important; + min-width: 0 !important; + max-width: calc(100vw - #{$sp-8}) !important; + padding: $sp-6 !important; + + :global(.react-responsive-modal-closeButton) { + right: $sp-4; + top: $sp-4; + + svg { + fill: $turq-160 !important; + } + } + + @include ltesm { + max-width: calc(100vw - #{$sp-4}) !important; + padding: $sp-4 !important; + width: calc(100vw - #{$sp-4}) !important; + } +} + +.body { + margin: 0 !important; + padding: 0 !important; +} + +.title { + color: $black-100; + font-family: $font-roboto; + font-size: 18px; + font-weight: $font-weight-bold; + line-height: 24px; + margin: 0; + padding-right: $sp-8; +} + +.content { + display: flex; + flex-direction: column; + gap: $sp-5; + min-width: 0; +} + +.summary { + align-items: baseline; + border-bottom: 1px solid $black-10; + display: flex; + gap: $sp-2; + padding-bottom: $sp-4; +} + +.summaryValue { + color: $turq-160; + font-family: $font-barlow-condensed; + font-size: 34px; + font-weight: $font-weight-medium; + line-height: 36px; +} + +.summaryLabel { + color: $black-80; + font-family: $font-roboto; + font-size: 13px; + line-height: 18px; +} + +.table { + border: 1px solid $black-10; + border-radius: 6px; + overflow: hidden; +} + +.tableHeader, +.tableRow { + display: grid; + gap: $sp-3; + grid-template-columns: minmax(0, 1fr) 64px 82px; +} + +.tableHeader { + background: $black-5; + color: $black-80; + font-family: $font-roboto; + font-size: 11px; + font-weight: $font-weight-bold; + line-height: 16px; + padding: $sp-2 $sp-3; + text-transform: uppercase; +} + +.tableRow { + align-items: center; + color: $black-100; + font-family: $font-roboto; + font-size: 13px; + line-height: 18px; + min-height: 44px; + padding: $sp-3; + + & + & { + border-top: 1px solid $black-10; + } +} + +.challengeLink { + color: $turq-160; + font-weight: $font-weight-bold; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover { + text-decoration: underline; + } +} + +.placement, +.points { + white-space: nowrap; +} + +.points { + font-weight: $font-weight-bold; + text-align: right; +} diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.tsx b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.tsx new file mode 100644 index 000000000..d0387da31 --- /dev/null +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.tsx @@ -0,0 +1,110 @@ +import { FC, useMemo } from 'react' + +import { EnvironmentConfig } from '~/config' +import { UserChallengePointsDetail, UserChallengePointsSummary } from '~/libs/core' +import { BaseModal } from '~/libs/ui' + +import { formatPlural } from '../../../lib' + +import styles from './MemberChallengePointsModal.module.scss' + +interface MemberChallengePointsModalProps { + challengePoints: UserChallengePointsSummary + onClose: () => void +} + +const numberFormatter = new Intl.NumberFormat('en-US') + +/** + * Formats challenge point values using the comma grouping shown in profile cards. + * + * @param {number} value - Raw point value to render. + * @returns {string} Locale-formatted point value. + */ +const formatPoints = (value: number): string => numberFormatter.format(value) + +/** + * Builds a challenge details URL from the configured Topcoder challenges base path. + * + * @param {string} challengeId - Challenge identifier from member-api. + * @returns {string} Absolute challenge details URL. + */ +const getChallengeUrl = (challengeId: string): string => ( + `${EnvironmentConfig.URLS.CHALLENGES_PAGE}/${challengeId}` +) + +const MemberChallengePointsModal: FC = ( + props: MemberChallengePointsModalProps, +) => { + const details = useMemo(() => ( + [...(props.challengePoints.details ?? [])] + .sort(( + detailA: UserChallengePointsDetail, + detailB: UserChallengePointsDetail, + ) => ( + detailA.challengeName.localeCompare(detailB.challengeName) + || detailA.placement - detailB.placement + )) + ), [props.challengePoints.details]) + + return ( + + Challenge Points + + )} + size='md' + > +
+
+ + {formatPoints(props.challengePoints.total)} + + + from + {' '} + {formatPoints(props.challengePoints.challenges)} + {' '} + {formatPlural(props.challengePoints.challenges, 'challenge')} + +
+ +
+
+ Challenge + Place + Points +
+ + {details.map((detail: UserChallengePointsDetail) => ( +
+ + {detail.challengeName || `Challenge ${detail.challengeId}`} + + + # + {detail.placement} + + + {formatPoints(detail.points)} + +
+ ))} +
+
+
+ ) +} + +export default MemberChallengePointsModal diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss index 0f146fb2e..0223fa569 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss @@ -41,13 +41,25 @@ } .challengePointsLink { + appearance: none; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; display: inline-flex; align-items: center; gap: 2px; + font-family: inherit; + font-size: inherit; margin-left: auto; font-weight: $font-weight-bold; + padding: 0; white-space: nowrap; + &:hover { + text-decoration: underline; + } + @include ltesm { width: 100%; margin-left: 0; diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx index 77e691bbb..4b1e39dc4 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx @@ -1,14 +1,22 @@ -import { FC, useCallback, useMemo } from 'react' +import { Dispatch, FC, SetStateAction, useCallback, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import classNames from 'classnames' -import { getRatingColor, MemberStats, useMemberStats, UserProfile, UserStats } from '~/libs/core' +import { + getRatingColor, + MemberStats, + useMemberStats, + UserChallengePointsSummary, + UserProfile, + UserStats, +} from '~/libs/core' import { IconOutline } from '~/libs/ui' import { getActiveTracks, getMemberChallengePoints, MemberStatsTrack } from '../../../hooks' import { formatPlural, WinnerIcon } from '../../../lib' import { MemberProfileContextValue, useMemberProfileContext } from '../../../member-profile/MemberProfile.context' +import MemberChallengePointsModal from './MemberChallengePointsModal' import styles from './MemberStatsBlock.module.scss' interface MemberStatsBlockProps { @@ -21,6 +29,18 @@ interface TrackDisplayStats { value: number } +interface ChallengePointsBarProps { + canViewBreakdown: boolean + challengeCount?: number + points: number + onOpenBreakdown: () => void +} + +interface TrackListItemProps { + getTrackRoute: (trackName: string, subTracks?: MemberStats[]) => string + track: MemberStatsTrack +} + const trackDisplayOrder = [ 'AI Engineering', 'Development', @@ -93,94 +113,160 @@ const sortTracksForDisplay = (tracks: MemberStatsTrack[]): MemberStatsTrack[] => }) ) +/** + * Renders the profile challenge-points summary and optional breakdown trigger. + * + * @param {ChallengePointsBarProps} props - Challenge point display data and action. + * @returns {JSX.Element} The challenge-points summary bar. + */ +const ChallengePointsBar: FC = props => ( +
+ + Challenge Points + + + {formatStatValue(props.points)} + + {props.challengeCount !== undefined && ( + + from + {' '} + {formatStatValue(props.challengeCount)} + {' '} + {formatPlural(props.challengeCount, 'challenge')} + + )} + {props.canViewBreakdown && ( + + )} +
+) + +/** + * Renders one linked member-stats track row. + * + * @param {TrackListItemProps} props - Track data and route resolver. + * @returns {JSX.Element} The track list item. + */ +const TrackListItem: FC = props => { + const displayStats = getTrackDisplayStats(props.track) + + return ( +
  • + + {props.track.name} +
    + {displayStats.indicator === 'winner' && ( + + )} + {displayStats.indicator === 'rating' && ( + + )} + + + {formatStatValue(displayStats.value)} + + + {displayStats.label} + + + +
    + +
  • + ) +} + const MemberStatsBlock: FC = props => { const { statsRoute }: MemberProfileContextValue = useMemberProfileContext() + const [isPointsModalOpen, setIsPointsModalOpen]: [boolean, Dispatch>] + = useState(false) const memberStats: UserStats | undefined = useMemberStats(props.profile.handle) const activeTracks = useMemo(() => getActiveTracks(memberStats), [memberStats]) const displayTracks = useMemo(() => sortTracksForDisplay(activeTracks), [activeTracks]) - const challengePoints = useMemo(() => getMemberChallengePoints(memberStats), [memberStats]) + const profileChallengePoints: UserChallengePointsSummary | undefined = ( + (props.profile.challengePoints?.total ?? 0) > 0 + ? props.profile.challengePoints + : undefined + ) + const statsChallengePoints = useMemo(() => getMemberChallengePoints(memberStats), [memberStats]) + const challengePoints = profileChallengePoints?.total + ?? ((statsChallengePoints ?? 0) > 0 ? statsChallengePoints : undefined) + const challengePointsChallenges = profileChallengePoints?.challenges ?? memberStats?.challenges + const canViewChallengePointsBreakdown = (profileChallengePoints?.details?.length ?? 0) > 0 const getTrackRoute = useCallback((trackName: string, subTracks?: MemberStats[]): string => { const subTrackName = subTracks?.length === 1 ? subTracks[0].name : '' return statsRoute(props.profile.handle, trackName, subTrackName) }, [props.profile.handle, statsRoute]) - return displayTracks?.length === 0 ? <> : ( + function handleOpenPointsModal(): void { + setIsPointsModalOpen(true) + } + + function handleClosePointsModal(): void { + setIsPointsModalOpen(false) + } + + return displayTracks.length === 0 && challengePoints === undefined ? <> : (
    {challengePoints !== undefined && ( -
    - - Challenge Points - - - {formatStatValue(challengePoints)} - - {memberStats?.challenges !== undefined && ( - - from - {' '} - {formatStatValue(memberStats.challenges)} - {' '} - {formatPlural(memberStats.challenges, 'challenge')} - - )} - - View breakdown - - -
    + )} -
    -
    -

    - - Member Stats - -

    -
      - {displayTracks.map(track => ( -
    • - - {track.name} -
      - {getTrackDisplayStats(track).indicator === 'winner' && ( - - )} - {getTrackDisplayStats(track).indicator === 'rating' && ( - - )} - - - {formatStatValue(getTrackDisplayStats(track).value)} - - - {getTrackDisplayStats(track).label} - - - -
      - -
    • - ))} -
    -

    - - Topcoder challenges are competitive events where community members collaborate - on smaller tasks to complete a project, - striving to showcase their skills and outperform others. - -

    + {displayTracks.length > 0 && ( +
    +
    +

    + + Member Stats + +

    +
      + {displayTracks.map(track => ( + + ))} +
    +

    + + Topcoder challenges are competitive events where community members collaborate + on smaller tasks to complete a project, + striving to showcase their skills and outperform others. + +

    +
    -
    + )} + {isPointsModalOpen && profileChallengePoints && ( + + )}
    ) } diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss index d1c6e5196..2bdfef686 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss @@ -167,15 +167,23 @@ position: relative; } +.chartScale { + bottom: 0; + left: $sp-5; + position: absolute; + right: $sp-5; + top: 0; +} + .bars { align-items: flex-end; bottom: $sp-7; display: flex; gap: 2px; height: 176px; - left: $sp-5; + left: 0; position: absolute; - right: $sp-5; + right: 0; } .bar { @@ -260,10 +268,10 @@ color: $black-80; font-family: $font-roboto; font-size: 9px; - left: $sp-5; + left: 0; line-height: 12px; position: absolute; - right: $sp-5; + right: 0; span { position: absolute; diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx index c5a7e2e79..2caff8050 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx @@ -371,7 +371,7 @@ const MemberRatingInfoModal: FC = (props: MemberRati
    {distributionRanges.length > 0 ? ( - <> +
    {distributionRanges.map((range: RatingDistributionRange) => ( = (props: MemberRati ))}
    - +
    ) : (

    Rating distribution is loading.

    )} diff --git a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.module.scss b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.module.scss index 22e6cacff..6e8088213 100644 --- a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.module.scss +++ b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.module.scss @@ -52,3 +52,44 @@ flex-wrap: wrap; gap: $sp-2; } + +.additionalSkillsWrap { + display: flex; + flex-direction: column; +} + +.additionalSkillsToggle { + @include resetBtnStyle; + + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-4; + width: 100%; + padding: 0; + color: $black-100; + cursor: pointer; + text-align: left; + + &:focus-visible { + outline: 2px solid $link-blue-dark; + outline-offset: $sp-1; + border-radius: $sp-1; + } +} + +.additionalSkillsTitle { + font-size: 16px; + line-height: 20px; + font-weight: $font-weight-bold; +} + +.additionalSkillsIcon { + flex: 0 0 auto; + width: $sp-6; + height: $sp-6; +} + +.additionalSkillsContent:not([hidden]) { + margin-top: $sp-4; +} diff --git a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx index 334f5f9b0..537dd80c5 100644 --- a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx +++ b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx @@ -5,7 +5,7 @@ import { filter, orderBy } from 'lodash' import { getMemberSkillDetails, UserProfile, UserSkill, UserSkillDisplayModes } from '~/libs/core' import { GroupedSkillsUI, HowSkillsWorkModal, isSkillVerified, SkillPill, useLocalStorage } from '~/libs/shared' -import { Button } from '~/libs/ui' +import { Button, IconSolid } from '~/libs/ui' import { AddButton, EditMemberPropertyBtn, EmptySection } from '../../components' import { EDIT_MODE_QUERY_PARAM, profileEditModes } from '../../config' @@ -76,6 +76,15 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp const [principalIntroModalVisible, setPrincipalIntroModalVisible]: [boolean, Dispatch>] = useState(false) + const [ + isAdditionalSkillsExpanded, + setIsAdditionalSkillsExpanded, + ]: [boolean, Dispatch>] = useState(false) + + useEffect(() => { + setIsAdditionalSkillsExpanded(false) + }, [props.profile.handle]) + useEffect(() => { if (props.authProfile && editMode === profileEditModes.skills) { setIsEditMode(true) @@ -133,6 +142,19 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp setPrincipalIntroModalVisible(false) } + /** + * Toggles visibility for the Additional Skills section from the section arrow. + * + * Used by the Additional Skills header button to expand or collapse grouped + * skills on the member profile page. + * + * @returns {void} Updates local component state and returns no value. + * @throws This handler does not throw errors. + */ + function handleAdditionalSkillsToggle(): void { + setIsAdditionalSkillsExpanded(isExpanded => !isExpanded) + } + const fetchSkillDetails = useCallback((skillId: string) => getMemberSkillDetails(props.profile.handle, skillId) .catch(e => { if (e.response.status === 403) { @@ -193,15 +215,38 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp )} {additionalSkills.length > 0 && (
    - {principalSkills.length > 0 && ( -
    +
    - )} - + + {isAdditionalSkillsExpanded ? ( +
    )} {!memberSkills.length && ( diff --git a/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.tsx b/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.tsx index 6d204f7d2..2d9c5e93f 100644 --- a/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.tsx +++ b/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.tsx @@ -36,6 +36,7 @@ const MemberTCAchievements: FC = (props: MemberTCAchi const tcoTrips: number = useMemo(() => memberBadges?.rows.filter( (badge: UserBadge) => /TCO.*Trip Winner/.test(badge.org_badge.badge_name), ).length || 0, [memberBadges]) + const hasChallengePoints: boolean = (props.profile.challengePoints?.total ?? 0) > 0 const renderDefaultRoute = useCallback(() => ( = (props: MemberTCAchi /> ), [memberStats, props.profile, tcoQualifications, tcoTrips, tcoWins]) - if (!memberStats?.challenges && !tcoWins && !tcoQualifications && !memberBadges?.rows.length) { + if ( + !memberStats?.challenges + && !hasChallengePoints + && !tcoWins + && !tcoQualifications + && !memberBadges?.rows.length + ) { return <> } diff --git a/src/libs/core/lib/profile/user-profile.model.ts b/src/libs/core/lib/profile/user-profile.model.ts index c37611ee8..61b1c3061 100644 --- a/src/libs/core/lib/profile/user-profile.model.ts +++ b/src/libs/core/lib/profile/user-profile.model.ts @@ -10,6 +10,20 @@ export enum NamesAndHandleAppearance { export type AvailabilityType = 'FULL_TIME' | 'PART_TIME' +export interface UserChallengePointsDetail { + challengeId: string + challengeName: string + placement: number + points: number + userId: number +} + +export interface UserChallengePointsSummary { + challenges: number + details: Array + total: number +} + export interface UserProfile { addresses?: Array<{ city?: string @@ -46,6 +60,7 @@ export interface UserProfile { updatedAt: number userId: number namesAndHandleAppearance: NamesAndHandleAppearance + challengePoints?: UserChallengePointsSummary identityVerified?: boolean recentActivity?: boolean From 22dce22f283080992b5577cc78dce40407af5e97 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 4 Jun 2026 15:11:39 +1000 Subject: [PATCH 05/73] More profile updates --- .../about-me/AboutMe.module.scss | 25 +++++ .../src/member-profile/about-me/AboutMe.tsx | 35 ++++++- .../about-me/AboutMe.utils.spec.ts | 51 ++++++++++ .../member-profile/about-me/AboutMe.utils.ts | 66 +++++++++++++ .../CommunityAwards.module.scss | 96 ++++++++++--------- .../community-awards/CommunityAwards.tsx | 77 ++++++++++----- .../tc-achievements/MemberTCAchievements.tsx | 2 +- .../DefaultAchievementsView.tsx | 3 - 8 files changed, 278 insertions(+), 77 deletions(-) create mode 100644 src/apps/profiles/src/member-profile/about-me/AboutMe.utils.spec.ts create mode 100644 src/apps/profiles/src/member-profile/about-me/AboutMe.utils.ts diff --git a/src/apps/profiles/src/member-profile/about-me/AboutMe.module.scss b/src/apps/profiles/src/member-profile/about-me/AboutMe.module.scss index a5551e00a..83f950524 100644 --- a/src/apps/profiles/src/member-profile/about-me/AboutMe.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/AboutMe.module.scss @@ -19,6 +19,31 @@ } } +.bioWrap { + p { + margin-bottom: $sp-2; + } +} + +.bioToggle { + appearance: none; + background: transparent; + border: 0; + color: $link-blue-dark; + cursor: pointer; + font-family: $font-roboto; + font-size: 16px; + font-weight: $font-weight-bold; + line-height: 24px; + padding: 0; + text-align: left; + + &:hover { + color: darken($link-blue-dark, 5); + text-decoration: underline; + } +} + .empty { height: auto; margin-top: $sp-4; diff --git a/src/apps/profiles/src/member-profile/about-me/AboutMe.tsx b/src/apps/profiles/src/member-profile/about-me/AboutMe.tsx index b487c0259..51a10da6a 100644 --- a/src/apps/profiles/src/member-profile/about-me/AboutMe.tsx +++ b/src/apps/profiles/src/member-profile/about-me/AboutMe.tsx @@ -8,8 +8,10 @@ import { NamesAndHandleAppearance, useMemberTraits, UserProfile, UserTraitIds, U import { AddButton, EditMemberPropertyBtn, EmptySection } from '../../components' import { EDIT_MODE_QUERY_PARAM, profileEditModes } from '../../config' import { canSeePhones, getFirstProfileSelfTitle } from '../../lib/helpers' +import { CommunityAwards } from '../community-awards' import { Phones } from '../phones' +import { getTruncatedBio, TruncatedBio } from './AboutMe.utils' import { ModifyAboutMeModal } from './ModifyAboutMeModal' import MemberRatingCard from './MemberRatingCard/MemberRatingCard' import styles from './AboutMe.module.scss' @@ -38,6 +40,14 @@ const AboutMe: FC = (props: AboutMeProps) => { [memberPersonalizationTraits], ) + const truncatedBio: TruncatedBio = useMemo( + () => getTruncatedBio(props.profile.description), + [props.profile.description], + ) + + const [isBioExpanded, setIsBioExpanded]: [boolean, Dispatch>] + = useState(false) + const hasEmptyDescription = useMemo(() => ( props.profile && !props.profile.description ), [props.profile]) @@ -49,12 +59,20 @@ const AboutMe: FC = (props: AboutMeProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.authProfile]) + useEffect(() => { + setIsBioExpanded(false) + }, [props.profile.description]) + const canEdit: boolean = props.authProfile?.handle === props.profile.handle function handleEditClick(): void { setIsEditMode(!isEditMode) } + function handleBioToggleClick(): void { + setIsBioExpanded(!isBioExpanded) + } + function handleEditModalClose(): void { setIsEditMode(false) } @@ -109,7 +127,22 @@ const AboutMe: FC = (props: AboutMeProps) => { )} )} -

    {props.profile?.description}

    + {!hasEmptyDescription && ( +
    +

    {isBioExpanded ? props.profile.description : truncatedBio.text}

    + {truncatedBio.isTruncated && ( + + )} +
    + )} + + { isEditMode && ( diff --git a/src/apps/profiles/src/member-profile/about-me/AboutMe.utils.spec.ts b/src/apps/profiles/src/member-profile/about-me/AboutMe.utils.spec.ts new file mode 100644 index 000000000..654385253 --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/AboutMe.utils.spec.ts @@ -0,0 +1,51 @@ +import { + getTruncatedBio, + PROFILE_BIO_TRUNCATION_LENGTH, +} from './AboutMe.utils' + +describe('getTruncatedBio', () => { + it('returns short bios without truncation', () => { + expect(getTruncatedBio('Topcoder member')) + .toEqual({ + isTruncated: false, + text: 'Topcoder member', + }) + }) + + it('matches the Figma bio preview length before adding the suffix', () => { + const bio = [ + 'I am a highly skilled JavaScript Developer with a passion for building dynamic and interactive web', + 'applications. With several years of experience in the field, I possess a deep understanding of', + 'JavaScript\'s intricacies.', + ].join(' ') + const expectedBio = [ + 'I am a highly skilled JavaScript Developer with a passion for building dynamic and interactive web', + 'applications. With several years of experience in the field, I possess a deep understanding of', + 'JavaScript\'s...', + ].join(' ') + + expect(getTruncatedBio(bio)) + .toEqual({ + isTruncated: true, + text: expectedBio, + }) + }) + + it('backs up to the closest previous word when the limit lands mid-word', () => { + expect(getTruncatedBio('The quick brown fox jumps', 18)) + .toEqual({ + isTruncated: true, + text: 'The quick brown...', + }) + }) + + it('uses the configured profile bio preview length by default', () => { + const bio = `${'a'.repeat(PROFILE_BIO_TRUNCATION_LENGTH)} more text` + + expect(getTruncatedBio(bio)) + .toEqual({ + isTruncated: true, + text: `${'a'.repeat(PROFILE_BIO_TRUNCATION_LENGTH)}...`, + }) + }) +}) diff --git a/src/apps/profiles/src/member-profile/about-me/AboutMe.utils.ts b/src/apps/profiles/src/member-profile/about-me/AboutMe.utils.ts new file mode 100644 index 000000000..7bf3fa15b --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/AboutMe.utils.ts @@ -0,0 +1,66 @@ +export interface TruncatedBio { + isTruncated: boolean + text: string +} + +export const PROFILE_BIO_TRUNCATION_LENGTH = 206 + +/** + * Returns profile bio text for the collapsed AboutMe view. + * + * Used by the member profile page to match the Figma bio preview length while + * preserving full words when possible. The returned text includes the trailing + * three-dot suffix only when the bio exceeds the configured limit. + * + * @param {string | undefined} bio - The full profile bio from the member profile API. + * @param {number} maxLength - Maximum visible characters before the suffix is added. + * @returns {TruncatedBio} Display text and whether the full bio was shortened. + * @throws This function does not raise exceptions. + */ +export const getTruncatedBio = ( + bio: string | undefined, + maxLength: number = PROFILE_BIO_TRUNCATION_LENGTH, +): TruncatedBio => { + const text = bio?.trim() ?? '' + const safeMaxLength = Math.max(0, maxLength) + + if (text.length <= safeMaxLength) { + return { + isTruncated: false, + text, + } + } + + const textAtLimit = text.slice(0, safeMaxLength) + .trimEnd() + + if (!textAtLimit) { + return { + isTruncated: true, + text: '...', + } + } + + if (/\s/.test(text.charAt(safeMaxLength))) { + return { + isTruncated: true, + text: `${textAtLimit}...`, + } + } + + let wordBoundaryIndex = textAtLimit.length + + while (wordBoundaryIndex > 0 && !/\s/.test(textAtLimit.charAt(wordBoundaryIndex - 1))) { + wordBoundaryIndex -= 1 + } + + const wordBoundedText = wordBoundaryIndex > 0 + ? textAtLimit.slice(0, wordBoundaryIndex) + .trimEnd() + : textAtLimit + + return { + isTruncated: true, + text: `${wordBoundedText}...`, + } +} diff --git a/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.module.scss b/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.module.scss index 36bce8596..b3dc7f444 100644 --- a/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.module.scss +++ b/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.module.scss @@ -3,65 +3,67 @@ .container { display: flex; flex-direction: column; - padding-bottom: $sp-4; - - @include ltelg { - margin-top: $sp-4; - } + margin-top: $sp-8; .title { display: flex; - justify-content: flex-end; align-items: center; + margin-bottom: $sp-4; } .badges { + display: grid; + grid-template-columns: repeat(4, 56px); + gap: $sp-4; + } + + .badgeButton { + appearance: none; + align-items: center; + background: transparent; + border: 0; + cursor: pointer; display: flex; - column-gap: $sp-4; - row-gap: $sp-4; - flex-wrap: wrap; + height: 56px; + justify-content: center; + padding: 0; + transition: transform 0.15s ease-in-out; + width: 56px; - @include ltelg { - flex-direction: column; + &:hover { + transform: translateY(-2px); } - .badgeCard { - background-color: $tc-white; - padding: $sp-4; - display: flex; - cursor: pointer; - min-width: 280px; - border-radius: 8px; - align-items: center; - transition: 0.25s ease-in-out; - box-shadow: 0 0 0 rgba(0, 0, 0, 0); - - @include ltelg { - padding-bottom: 0; - padding-left: $sp-2; - min-width: 100%; - } + &:focus-visible { + border-radius: 4px; + outline: 2px solid $link-blue-dark; + outline-offset: 2px; + } + } - &:hover { - box-shadow: 0 0 16px rgba(22, 103, 154, 0.5); - } + .badgeImage { + max-height: 56px; + max-width: 56px; + } +} - .badgeImageWrap { - width: 48px; - height: 48px; - .badgeImage { - height: 48px; - margin: auto; - } - } +.moreBadgesButton { + appearance: none; + align-self: flex-start; + background: transparent; + border: 0; + color: $link-blue-dark; + cursor: pointer; + font-family: $font-roboto; + font-size: 16px; + font-weight: $font-weight-bold; + line-height: 24px; + margin-top: $sp-4; + padding: 0; + text-align: left; - .badgeTitle { - font-size: 14px; - font-weight: $font-weight-bold; - line-height: 16px; - margin-left: $sp-2; - max-width: 130px; - } - } + &:hover { + color: darken($link-blue-dark, 5); + text-decoration: underline; } -} \ No newline at end of file +} diff --git a/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.tsx b/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.tsx index b5e8d0af5..613c38c79 100644 --- a/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.tsx +++ b/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.tsx @@ -1,24 +1,29 @@ -import { Dispatch, FC, SetStateAction, useCallback, useState } from 'react' -import { Link } from 'react-router-dom' +import { Dispatch, FC, SetStateAction, useCallback, useEffect, useState } from 'react' import { bind } from 'lodash' import { useMemberBadges, UserBadge, UserBadgesResponse, UserProfile } from '~/libs/core' -import { Button } from '~/libs/ui' import { MemberBadgeModal } from '../../components' import styles from './CommunityAwards.module.scss' +const COLLAPSED_BADGE_COUNT = 4 + interface CommunityAwardsProps { profile: UserProfile | undefined } const CommunityAwards: FC = (props: CommunityAwardsProps) => { - const memberBadges: UserBadgesResponse | undefined = useMemberBadges(props.profile?.userId as number, { limit: 6 }) + const memberBadges: UserBadgesResponse | undefined = useMemberBadges(props.profile?.userId as number, { + limit: 500, + }) const [isBadgeDetailsOpen, setIsBadgeDetailsOpen]: [boolean, Dispatch>] = useState(false) + const [isAwardsExpanded, setIsAwardsExpanded]: [boolean, Dispatch>] + = useState(false) + const [selectedBadge, setSelectedBadge]: [UserBadge | undefined, Dispatch>] = useState(undefined) @@ -27,44 +32,66 @@ const CommunityAwards: FC = (props: CommunityAwardsProps) setSelectedBadge(badge) }, []) - return memberBadges && memberBadges.count ? ( + useEffect(() => { + setIsAwardsExpanded(false) + }, [props.profile?.userId]) + + function handleAwardsExpandClick(): void { + setIsAwardsExpanded(true) + } + + function handleMemberBadgeModalClose(): void { + setIsBadgeDetailsOpen(false) + } + + const badges: UserBadge[] = memberBadges?.rows ?? [] + const visibleBadges: UserBadge[] = isAwardsExpanded + ? badges + : badges.slice(0, COLLAPSED_BADGE_COUNT) + const additionalBadgeCount: number = Math.max((memberBadges?.count ?? badges.length) - COLLAPSED_BADGE_COUNT, 0) + + return badges.length ? (
    - -
    { - memberBadges.rows.map(badge => ( -
    ( +
    + {`Topcoder + )) }
    + {!isAwardsExpanded && additionalBadgeCount > 0 && ( + + )} + { selectedBadge && ( ) diff --git a/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.tsx b/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.tsx index 2d9c5e93f..1523bbf53 100644 --- a/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.tsx +++ b/src/apps/profiles/src/member-profile/tc-achievements/MemberTCAchievements.tsx @@ -53,7 +53,7 @@ const MemberTCAchievements: FC = (props: MemberTCAchi && !hasChallengePoints && !tcoWins && !tcoQualifications - && !memberBadges?.rows.length + && !tcoTrips ) { return <> } diff --git a/src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.tsx b/src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.tsx index dba2442a4..8d64e8d39 100644 --- a/src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.tsx +++ b/src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.tsx @@ -2,7 +2,6 @@ import { FC } from 'react' import { UserProfile, UserStats } from '~/libs/core' -import { CommunityAwards } from '../../community-awards' import { MemberStatsBlock } from '../../../components/tc-achievements/MemberStatsBlock' import { TcSpecialRolesBanner } from '../../../components/tc-achievements/TcSpecialRolesBanner' import { TCOWinsBanner } from '../../../components/tc-achievements/TCOWinsBanner' @@ -33,8 +32,6 @@ const DefaultAchievementsView: FC = props => (
    - - ) From 53d7dbda943b246e93406a4615a8e86e84aa6ac9 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 4 Jun 2026 17:06:06 +1000 Subject: [PATCH 06/73] UI fixes for bugs noted --- .../MemberChallengePointsModal.module.scss | 62 +++----- .../MemberChallengePointsModal.tsx | 39 +---- .../MemberStatsBlock.module.scss | 29 ++-- .../MemberStatsBlock/MemberStatsBlock.tsx | 139 ++++++++++-------- .../tc-achievements/MemberStatsBlock/index.ts | 2 +- .../MemberRatingCard.module.scss | 14 +- .../MemberRatingInfoModal.module.scss | 10 +- .../MemberRatingInfoModal.tsx | 6 +- .../DefaultAchievementsView.tsx | 47 +++--- 9 files changed, 174 insertions(+), 174 deletions(-) diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.module.scss b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.module.scss index 7d471efac..d6c705d2f 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.module.scss +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.module.scss @@ -1,7 +1,7 @@ @import "@libs/ui/styles/includes"; .modal { - width: 520px !important; + width: 640px !important; min-width: 0 !important; max-width: calc(100vw - #{$sp-8}) !important; padding: $sp-6 !important; @@ -40,36 +40,17 @@ .content { display: flex; flex-direction: column; - gap: $sp-5; + gap: $sp-4; min-width: 0; } -.summary { - align-items: baseline; - border-bottom: 1px solid $black-10; - display: flex; - gap: $sp-2; - padding-bottom: $sp-4; -} - -.summaryValue { - color: $turq-160; - font-family: $font-barlow-condensed; - font-size: 34px; - font-weight: $font-weight-medium; - line-height: 36px; -} - -.summaryLabel { - color: $black-80; - font-family: $font-roboto; - font-size: 13px; - line-height: 18px; +.divider { + border: 0; + border-top: 1px solid $black-10; + margin: 0; } .table { - border: 1px solid $black-10; - border-radius: 6px; overflow: hidden; } @@ -77,28 +58,32 @@ .tableRow { display: grid; gap: $sp-3; - grid-template-columns: minmax(0, 1fr) 64px 82px; + grid-template-columns: 56px minmax(0, 1fr) 72px; + + @include ltesm { + gap: $sp-2; + grid-template-columns: 44px minmax(0, 1fr) 52px; + } } .tableHeader { - background: $black-5; - color: $black-80; + border-bottom: 1px solid $black-20; + color: $black-100; font-family: $font-roboto; - font-size: 11px; + font-size: 14px; font-weight: $font-weight-bold; - line-height: 16px; - padding: $sp-2 $sp-3; - text-transform: uppercase; + line-height: 20px; + padding: $sp-3 0; } .tableRow { align-items: center; color: $black-100; font-family: $font-roboto; - font-size: 13px; - line-height: 18px; - min-height: 44px; - padding: $sp-3; + font-size: 14px; + line-height: 20px; + min-height: 52px; + padding: $sp-3 0; & + & { border-top: 1px solid $black-10; @@ -107,11 +92,8 @@ .challengeLink { color: $turq-160; - font-weight: $font-weight-bold; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; &:hover { text-decoration: underline; diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.tsx b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.tsx index d0387da31..08aad3ed9 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.tsx +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.tsx @@ -1,11 +1,9 @@ -import { FC, useMemo } from 'react' +import { FC } from 'react' import { EnvironmentConfig } from '~/config' import { UserChallengePointsDetail, UserChallengePointsSummary } from '~/libs/core' import { BaseModal } from '~/libs/ui' -import { formatPlural } from '../../../lib' - import styles from './MemberChallengePointsModal.module.scss' interface MemberChallengePointsModalProps { @@ -36,16 +34,7 @@ const getChallengeUrl = (challengeId: string): string => ( const MemberChallengePointsModal: FC = ( props: MemberChallengePointsModalProps, ) => { - const details = useMemo(() => ( - [...(props.challengePoints.details ?? [])] - .sort(( - detailA: UserChallengePointsDetail, - detailB: UserChallengePointsDetail, - ) => ( - detailA.challengeName.localeCompare(detailB.challengeName) - || detailA.placement - detailB.placement - )) - ), [props.challengePoints.details]) + const details: UserChallengePointsDetail[] = props.challengePoints.details ?? [] return ( = ( spacer={false} title={(

    - Challenge Points + POINTS BREAKDOWN

    )} size='md' >
    -
    - - {formatPoints(props.challengePoints.total)} - - - from - {' '} - {formatPoints(props.challengePoints.challenges)} - {' '} - {formatPlural(props.challengePoints.challenges, 'challenge')} - -
    +
    - Challenge Place + Challenge Points
    {details.map((detail: UserChallengePointsDetail) => (
    + + {detail.placement} + = ( > {detail.challengeName || `Challenge ${detail.challengeId}`} - - # - {detail.placement} - {formatPoints(detail.points)} diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss index 0223fa569..67e80a138 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss @@ -6,22 +6,29 @@ container-type: inline-size; } +.challengePointsStandalone { + container-type: inline-size; + margin: $sp-4 0 0; + width: 100%; +} + .challengePointsBar { display: flex; align-items: center; - gap: $sp-1; - min-height: 34px; - padding: 0 $sp-4; - border-radius: 8px 8px 0 0; + gap: $sp-2; + min-height: 66px; + padding: 0 $sp-7; + border-radius: 8px; background: linear-gradient(90deg, #008A72, #005B86); color: $tc-white; font-family: $font-roboto; - font-size: 12px; - line-height: 16px; + font-size: 18px; + line-height: 24px; @include ltesm { flex-wrap: wrap; - padding: $sp-2 $sp-3; + min-height: 86px; + padding: $sp-4; } } @@ -31,9 +38,9 @@ .challengePointsValue { font-family: $font-barlow-condensed; - font-size: 20px; + font-size: 32px; font-weight: $font-weight-medium; - line-height: 22px; + line-height: 34px; } .challengePointsMeta { @@ -75,10 +82,6 @@ padding: $sp-8 $sp-9 $sp-7; min-height: 100%; - .challengePointsBar + & { - border-radius: 0 0 8px 8px; - } - @include ltelg { padding: $sp-5; } diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx index 4b1e39dc4..be35f4231 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx @@ -23,6 +23,11 @@ interface MemberStatsBlockProps { profile: UserProfile } +interface MemberChallengePointsBarProps { + memberStats?: UserStats + profile: UserProfile +} + interface TrackDisplayStats { indicator?: 'rating' | 'winner' label: string @@ -122,7 +127,7 @@ const sortTracksForDisplay = (tracks: MemberStatsTrack[]): MemberStatsTrack[] => const ChallengePointsBar: FC = props => (
    - Challenge Points + Challenge Points: {formatStatValue(props.points)} @@ -149,6 +154,52 @@ const ChallengePointsBar: FC = props => (
    ) +/** + * Renders the standalone challenge-points row and its optional breakdown modal. + * + * @param {MemberChallengePointsBarProps} props - Profile data plus already-fetched member stats fallback data. + * @returns {JSX.Element} The challenge-points row, or an empty fragment when no points exist. + */ +export const MemberChallengePointsBar: FC = props => { + const [isPointsModalOpen, setIsPointsModalOpen]: [boolean, Dispatch>] + = useState(false) + const profileChallengePoints: UserChallengePointsSummary | undefined = ( + (props.profile.challengePoints?.total ?? 0) > 0 + ? props.profile.challengePoints + : undefined + ) + const statsChallengePoints = useMemo(() => getMemberChallengePoints(props.memberStats), [props.memberStats]) + const challengePoints = profileChallengePoints?.total + ?? ((statsChallengePoints ?? 0) > 0 ? statsChallengePoints : undefined) + const challengePointsChallenges = profileChallengePoints?.challenges ?? props.memberStats?.challenges + const canViewChallengePointsBreakdown = (profileChallengePoints?.details?.length ?? 0) > 0 + + function handleOpenPointsModal(): void { + setIsPointsModalOpen(true) + } + + function handleClosePointsModal(): void { + setIsPointsModalOpen(false) + } + + return challengePoints === undefined ? <> : ( +
    + + {isPointsModalOpen && profileChallengePoints && ( + + )} +
    + ) +} + /** * Renders one linked member-stats track row. * @@ -194,79 +245,43 @@ const TrackListItem: FC = props => { const MemberStatsBlock: FC = props => { const { statsRoute }: MemberProfileContextValue = useMemberProfileContext() - const [isPointsModalOpen, setIsPointsModalOpen]: [boolean, Dispatch>] - = useState(false) const memberStats: UserStats | undefined = useMemberStats(props.profile.handle) const activeTracks = useMemo(() => getActiveTracks(memberStats), [memberStats]) const displayTracks = useMemo(() => sortTracksForDisplay(activeTracks), [activeTracks]) - const profileChallengePoints: UserChallengePointsSummary | undefined = ( - (props.profile.challengePoints?.total ?? 0) > 0 - ? props.profile.challengePoints - : undefined - ) - const statsChallengePoints = useMemo(() => getMemberChallengePoints(memberStats), [memberStats]) - const challengePoints = profileChallengePoints?.total - ?? ((statsChallengePoints ?? 0) > 0 ? statsChallengePoints : undefined) - const challengePointsChallenges = profileChallengePoints?.challenges ?? memberStats?.challenges - const canViewChallengePointsBreakdown = (profileChallengePoints?.details?.length ?? 0) > 0 const getTrackRoute = useCallback((trackName: string, subTracks?: MemberStats[]): string => { const subTrackName = subTracks?.length === 1 ? subTracks[0].name : '' return statsRoute(props.profile.handle, trackName, subTrackName) }, [props.profile.handle, statsRoute]) - function handleOpenPointsModal(): void { - setIsPointsModalOpen(true) - } - - function handleClosePointsModal(): void { - setIsPointsModalOpen(false) - } - - return displayTracks.length === 0 && challengePoints === undefined ? <> : ( + return displayTracks.length === 0 ? <> : (
    - {challengePoints !== undefined && ( - - )} - {displayTracks.length > 0 && ( -
    -
    -

    - - Member Stats - -

    -
      - {displayTracks.map(track => ( - - ))} -
    -

    - - Topcoder challenges are competitive events where community members collaborate - on smaller tasks to complete a project, - striving to showcase their skills and outperform others. - -

    -
    +
    +
    +

    + + Member Stats + +

    +
      + {displayTracks.map(track => ( + + ))} +
    +

    + + Topcoder challenges are competitive events where community members collaborate + on smaller tasks to complete a project, + striving to showcase their skills and outperform others. + +

    - )} - {isPointsModalOpen && profileChallengePoints && ( - - )} +
    ) } diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/index.ts b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/index.ts index 1ff7f5737..a15872bf7 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/index.ts +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/index.ts @@ -1 +1 @@ -export { default as MemberStatsBlock } from './MemberStatsBlock' +export { default as MemberStatsBlock, MemberChallengePointsBar } from './MemberStatsBlock' diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss index dfea59f85..fc34be845 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss @@ -17,6 +17,7 @@ border-radius: 8px; width: 286px; max-width: 100%; + margin: 0 auto; .valueWrap { appearance: none; @@ -94,14 +95,15 @@ } @include ltesm { - grid-template-columns: 1fr; - width: 100%; - justify-items: start; + grid-template-columns: 52px 104px auto; + column-gap: $sp-3; + justify-items: stretch; + width: 300px; .link { - grid-column: auto; - justify-self: start; - margin-top: 0; + grid-column: -2 / -1; + justify-self: end; + margin-top: $sp-1; } } } diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss index 2bdfef686..7ca5d6e92 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss @@ -69,7 +69,7 @@ overflow: hidden; @include ltesm { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -84,13 +84,19 @@ @include ltesm { padding: $sp-4; } + + &:first-child { + @include ltesm { + grid-column: 1 / -1; + } + } } .positionMetric { border-left: 1px solid $black-10; @include ltesm { - border-left: 0; + border-left: 1px solid $black-10; border-top: 1px solid $black-10; } } diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx index 2caff8050..6bf2e32f8 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx @@ -357,7 +357,11 @@ const MemberRatingInfoModal: FC = (props: MemberRati {percentileLabel} {percentileLabel === '--' ? '' : '%'} - {props.audienceLabel} + + of + {' '} + {props.audienceLabel.toLowerCase()} +
    diff --git a/src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.tsx b/src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.tsx index 8d64e8d39..408ec5374 100644 --- a/src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.tsx +++ b/src/apps/profiles/src/member-profile/tc-achievements/default-achievements-view/DefaultAchievementsView.tsx @@ -1,10 +1,11 @@ -import { FC } from 'react' +import { FC, useMemo } from 'react' import { UserProfile, UserStats } from '~/libs/core' -import { MemberStatsBlock } from '../../../components/tc-achievements/MemberStatsBlock' +import { MemberChallengePointsBar, MemberStatsBlock } from '../../../components/tc-achievements/MemberStatsBlock' import { TcSpecialRolesBanner } from '../../../components/tc-achievements/TcSpecialRolesBanner' import { TCOWinsBanner } from '../../../components/tc-achievements/TCOWinsBanner' +import { getActiveTracks, MemberStatsTrack } from '../../../hooks' import styles from './DefaultAchievementsView.module.scss' @@ -16,23 +17,33 @@ interface DefaultAchievementsViewProps { tcoTrips: number } -const DefaultAchievementsView: FC = props => ( - <> -

    Achievements @ Topcoder

    - -
    - {(props.tcoWins > 0 || props.tcoQualifications > 0 || props.tcoTrips > 0) && ( - +const DefaultAchievementsView: FC = props => { + const hasTcoBanner = props.tcoWins > 0 || props.tcoQualifications > 0 || props.tcoTrips > 0 + const activeTracks: MemberStatsTrack[] = useMemo(() => getActiveTracks(props.memberStats), [props.memberStats]) + const hasMemberStats = activeTracks.length > 0 + + return ( + <> +

    Achievements @ Topcoder

    + + + + {(hasTcoBanner || hasMemberStats) && ( +
    + {hasTcoBanner && ( + + )} + {hasMemberStats && } +
    )} - -
    - - -) + + + ) +} export default DefaultAchievementsView From 96e2d30c7a192ca1705f1fdf910fd02cb26cb9ea Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 5 Jun 2026 11:26:05 +1000 Subject: [PATCH 07/73] Tweaks for roles and engagement availability selection --- .../src/models/PersonalizationInfo.ts | 5 +- .../src/pages/open-to-work/index.tsx | 7 +- .../onboarding/src/redux/actions/member.ts | 1 - src/apps/profiles/src/lib/helpers.ts | 117 ++++++++++++- .../src/member-profile/about-me/AboutMe.tsx | 7 +- .../MemberRatingCard.module.scss | 48 ++++++ .../MemberRatingCard/MemberRatingCard.tsx | 102 ++++++++++- .../MemberRatingCard.utils.spec.ts | 18 ++ .../MemberRatingCard.utils.ts | 14 ++ .../ModifyPreferredRolesModal.module.scss | 17 ++ .../ModifyPreferredRolesModal.tsx | 158 ++++++++++++++++++ .../ModifyPreferredRolesModal/index.ts | 1 + .../OpenForGigsModifyModal.tsx | 9 +- .../profile-header/ProfileHeader.tsx | 34 +--- .../ModifyOpenToWorkModal.tsx | 54 +----- 15 files changed, 496 insertions(+), 96 deletions(-) create mode 100644 src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/ModifyPreferredRolesModal.module.scss create mode 100644 src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/ModifyPreferredRolesModal.tsx create mode 100644 src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/index.ts diff --git a/src/apps/onboarding/src/models/PersonalizationInfo.ts b/src/apps/onboarding/src/models/PersonalizationInfo.ts index ee6ac4adb..3750626b2 100644 --- a/src/apps/onboarding/src/models/PersonalizationInfo.ts +++ b/src/apps/onboarding/src/models/PersonalizationInfo.ts @@ -1,16 +1,15 @@ export interface OpenToWorkTrait { availability?: string - preferredRoles?: string[] } export default interface PersonalizationInfo { referAs?: string profileSelfTitle?: string + preferredRoles?: string[] shortBio?: string links?: Array<{ url: string; name: string }> openToWork?: { availability?: string, - preferredRoles?: string[], } } @@ -18,8 +17,8 @@ export const emptyPersonalizationInfo: () => PersonalizationInfo = () => ({ links: [], openToWork: { availability: '', - preferredRoles: [], }, + preferredRoles: [], profileSelfTitle: '', referAs: '', shortBio: '', diff --git a/src/apps/onboarding/src/pages/open-to-work/index.tsx b/src/apps/onboarding/src/pages/open-to-work/index.tsx index 224721b30..05971e92b 100644 --- a/src/apps/onboarding/src/pages/open-to-work/index.tsx +++ b/src/apps/onboarding/src/pages/open-to-work/index.tsx @@ -44,7 +44,6 @@ export const PageOpenToWorkContent: FC = props => { const [formValue, setFormValue] = useState({ availability: undefined, availableForGigs: !!props.availableForGigs, - preferredRoles: [], }) const [submitAttempted, setSubmitAttempted] = useState(false) @@ -66,7 +65,6 @@ export const PageOpenToWorkContent: FC = props => { ...prev, availability: openToWorkItem?.availability, availableForGigs: !!props.availableForGigs, - preferredRoles: openToWorkItem?.preferredRoles ?? [], })) }, [memberPersonalizationTraits, props.availableForGigs]) @@ -103,13 +101,14 @@ export const PageOpenToWorkContent: FC = props => { setLoading(true) const existing = memberPersonalizationTraits?.[0]?.traits?.data?.[0] || {} + const openToWork = { ...(existing.openToWork || {}) } + delete openToWork.preferredRoles const personalizationData = [{ ...existing, openToWork: { - ...(existing.openToWork || {}), + ...openToWork, availability: formValue.availability, - preferredRoles: formValue.preferredRoles, }, }] diff --git a/src/apps/onboarding/src/redux/actions/member.ts b/src/apps/onboarding/src/redux/actions/member.ts index 7b1f23b50..4ca3f2171 100644 --- a/src/apps/onboarding/src/redux/actions/member.ts +++ b/src/apps/onboarding/src/redux/actions/member.ts @@ -376,7 +376,6 @@ export const createPersonalizationsPayloadData: any = (personalizations: Persona openToWork: openToWork ? { availability: openToWork.availability, - preferredRoles: openToWork.preferredRoles, } : undefined, profileSelfTitle, diff --git a/src/apps/profiles/src/lib/helpers.ts b/src/apps/profiles/src/lib/helpers.ts index 1a854dc0b..daea29016 100644 --- a/src/apps/profiles/src/lib/helpers.ts +++ b/src/apps/profiles/src/lib/helpers.ts @@ -220,12 +220,24 @@ export function getAvailabilityLabel(value?: string): string | undefined { return availabilityOptions.find(o => o.value === value)?.label } +/** + * Converts stored preferred role option IDs into their human-readable labels. + * + * @param {string[]} values - Preferred role option IDs or already-readable role names. + * @returns {string[]} Labels that can be displayed in profile UI. + */ export function getPreferredRoleLabels(values: string[] = []): string[] { return values - .map(v => preferredRoleOptions.find(o => o.value === v)?.label) - .filter(Boolean) as string[] + .map(v => preferredRoleOptions.find(o => o.value === v)?.label ?? v) + .filter(Boolean) } +/** + * Formats role labels into a sentence-style list. + * + * @param {string[]} labels - Preferred role labels to display. + * @returns {string} A readable list joined with commas and "and". + */ export function formatRoleList(labels: string[]): string { if (labels.length === 1) return labels[0] if (labels.length === 2) return `${labels[0]} and ${labels[1]}` @@ -234,6 +246,61 @@ export function formatRoleList(labels: string[]): string { .join(', ')} and ${labels[labels.length - 1]}` } +/** + * Removes legacy preferred roles from open-to-work data before saving availability. + * + * @param {UserTrait} openToWork - Existing open-to-work trait data. + * @returns {UserTrait} Open-to-work data without nested preferred roles. + */ +export function getOpenToWorkWithoutPreferredRoles(openToWork: UserTrait = {}): UserTrait { + const nextOpenToWork = { ...openToWork } + delete nextOpenToWork.preferredRoles + + return nextOpenToWork +} + +/** + * Normalizes a preferred roles trait value into display text. + * + * @param {unknown} preferredRoles - Stored preferred roles from personalization traits. + * @returns {string} Text suitable for display in the profile role list. + */ +export function getPreferredRolesValueText(preferredRoles: unknown): string { + if (typeof preferredRoles === 'string') { + return preferredRoles.trim() + } + + if (Array.isArray(preferredRoles)) { + return getPreferredRoleLabels(preferredRoles.map(String)) + .join('\n') + } + + return '' +} + +/** + * Normalizes a preferred roles trait value into role option values. + * + * @param {unknown} preferredRoles - Stored preferred roles from personalization traits. + * @returns {string[]} Preferred role option IDs suitable for the role multiselect. + */ +export function getPreferredRoleValues(preferredRoles: unknown): string[] { + if (Array.isArray(preferredRoles)) { + return preferredRoles.map(String) + } + + if (typeof preferredRoles === 'string') { + return preferredRoles + .split(/[\n,;\u00b7\u2022]+/) + .map(role => role.trim() + .replace(/^[-*]\s+/, '')) + .filter(Boolean) + .map(role => preferredRoleOptions.find(option => option.label === role)?.value ?? role) + } + + return [] +} + function isObjectLike(value: any): boolean { return !!value && typeof value === 'object' } @@ -264,6 +331,52 @@ export function getFirstProfileSelfTitle(personalizationData: UserTrait[] = []): .find(Boolean) } +/** + * Returns preferred roles from the new personalization field with legacy fallback support. + * + * @param {UserTrait[]} personalizationData - Personalization trait data from the member API. + * @returns {string} Preferred roles text for the rating card and edit modal. + */ +export function getPreferredRolesText(personalizationData: UserTrait[] = []): string { + const flattenedData = flattenPersonalizationData(personalizationData) + const directPreferredRolesTrait = flattenedData.find( + trait => Object.prototype.hasOwnProperty.call(trait, 'preferredRoles'), + ) + + if (directPreferredRolesTrait) { + return getPreferredRolesValueText(directPreferredRolesTrait.preferredRoles) + } + + const legacyPreferredRoles = flattenedData.find( + trait => Array.isArray(trait.openToWork?.preferredRoles), + )?.openToWork?.preferredRoles + + return getPreferredRolesValueText(legacyPreferredRoles) +} + +/** + * Returns selected preferred role option values from the new field with legacy fallback support. + * + * @param {UserTrait[]} personalizationData - Personalization trait data from the member API. + * @returns {string[]} Preferred role option values for the edit multiselect. + */ +export function getPreferredRolesValues(personalizationData: UserTrait[] = []): string[] { + const flattenedData = flattenPersonalizationData(personalizationData) + const directPreferredRolesTrait = flattenedData.find( + trait => Object.prototype.hasOwnProperty.call(trait, 'preferredRoles'), + ) + + if (directPreferredRolesTrait) { + return getPreferredRoleValues(directPreferredRolesTrait.preferredRoles) + } + + const legacyPreferredRoles = flattenedData.find( + trait => Array.isArray(trait.openToWork?.preferredRoles), + )?.openToWork?.preferredRoles + + return getPreferredRoleValues(legacyPreferredRoles) +} + export function getPersonalizationLinks(personalizationData: UserTrait[] = []): UserTrait[] { const linksByKey = new Set() diff --git a/src/apps/profiles/src/member-profile/about-me/AboutMe.tsx b/src/apps/profiles/src/member-profile/about-me/AboutMe.tsx index 51a10da6a..10196bf77 100644 --- a/src/apps/profiles/src/member-profile/about-me/AboutMe.tsx +++ b/src/apps/profiles/src/member-profile/about-me/AboutMe.tsx @@ -99,7 +99,12 @@ const AboutMe: FC = (props: AboutMeProps) => { }

    - +

    {memberTitle}

    diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss index fc34be845..03702122d 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss @@ -109,6 +109,54 @@ } } +.preferredRolesWrap { + align-items: flex-start; + color: $black-100; + display: flex; + justify-content: center; + margin: $sp-4 auto 0; + max-width: 320px; + position: relative; + width: 100%; +} + +.preferredRolesList { + display: flex; + flex-wrap: wrap; + font-size: 16px; + gap: $sp-1 0; + justify-content: center; + line-height: 24px; + text-align: center; +} + +.preferredRole { + display: inline-flex; + white-space: normal; + + + .preferredRole::before { + content: '\00b7'; + margin: 0 $sp-2; + } +} + +.preferredRolesToggle { + color: $turq-160; + font-size: 16px; + line-height: 24px; + margin-left: $sp-2; + white-space: nowrap; + + &:hover { + text-decoration: underline; + } +} + +.preferredRolesEditButton { + color: $black-100; + padding-left: $sp-2 !important; +} + .ratingTooltip { --rt-opacity: 1; diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx index d55909d59..e6a071579 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx @@ -7,21 +7,28 @@ import { UserProfile, UserStats, UserStatsDistributionResponse, + UserTrait, useStatsDistribution, } from '~/libs/core' import { Tooltip } from '~/libs/ui' -import { numberToFixed } from '../../../lib' +import { EditMemberPropertyBtn } from '../../../components' +import { getPreferredRolesText, numberToFixed } from '../../../lib' import { calculateTopPercentileFromDistribution, getRatingAudienceLabel, getRatingDistributionQuery, + parsePreferredRolesText, } from './MemberRatingCard.utils' import { MemberRatingInfoModal } from './MemberRatingInfoModal' +import { ModifyPreferredRolesModal } from './ModifyPreferredRolesModal' import styles from './MemberRatingCard.module.scss' interface MemberRatingCardProps { + authProfile: UserProfile | undefined + memberPersonalizationTraitsData: UserTrait[] | undefined + mutatePersonalizationTraits: () => void profile: UserProfile } @@ -40,6 +47,16 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp const [isInfoModalOpen, setIsInfoModalOpen]: [boolean, Dispatch>] = useState(false) + const [isPreferredRolesModalOpen, setIsPreferredRolesModalOpen]: [ + boolean, + Dispatch> + ] = useState(false) + + const [arePreferredRolesExpanded, setArePreferredRolesExpanded]: [ + boolean, + Dispatch> + ] = useState(false) + const ratingDistributionQuery = useMemo(() => getRatingDistributionQuery(memberStats), [memberStats]) const ratingDistribution: UserStatsDistributionResponse | undefined = useStatsDistribution(ratingDistributionQuery) @@ -52,6 +69,15 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp const percentileLabel: string | undefined = maxPercentile ? `Top ${formatPercentile(maxPercentile)}%` : undefined + const canEditPreferredRoles: boolean = props.authProfile?.handle === props.profile.handle + const preferredRolesText: string = useMemo( + () => getPreferredRolesText(props.memberPersonalizationTraitsData), + [props.memberPersonalizationTraitsData], + ) + const preferredRoles: string[] = useMemo( + () => parsePreferredRolesText(preferredRolesText), + [preferredRolesText], + ) function handleInfoModalClose(): void { setIsInfoModalOpen(false) @@ -61,6 +87,67 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp setIsInfoModalOpen(true) } + function handlePreferredRolesModalClose(): void { + setIsPreferredRolesModalOpen(false) + } + + function handlePreferredRolesModalOpen(): void { + setIsPreferredRolesModalOpen(true) + } + + function handlePreferredRolesModalSave(): void { + setIsPreferredRolesModalOpen(false) + props.mutatePersonalizationTraits() + } + + function handlePreferredRolesToggle(): void { + setArePreferredRolesExpanded(!arePreferredRolesExpanded) + } + + function renderPreferredRoles(): JSX.Element { + const MAX_VISIBLE_PREFERRED_ROLES = 4 + + if (preferredRoles.length === 0 && !canEditPreferredRoles) { + return <> + } + + const visiblePreferredRoles = arePreferredRolesExpanded + ? preferredRoles + : preferredRoles.slice(0, MAX_VISIBLE_PREFERRED_ROLES) + const hasMorePreferredRoles = preferredRoles.length > MAX_VISIBLE_PREFERRED_ROLES + + return ( +
    + {preferredRoles.length > 0 && ( +
    + {visiblePreferredRoles.map((role: string) => ( + + {role} + + ))} + + {hasMorePreferredRoles && ( + + )} +
    + )} + + {canEditPreferredRoles && ( + + )} +
    + ) + } + return memberStats?.maxRating?.rating ? (
    @@ -118,6 +205,19 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp /> ) } + + {renderPreferredRoles()} + + { + isPreferredRolesModalOpen && ( + + ) + }
    ) : <> } diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts index 98b186449..3ef085171 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts @@ -4,6 +4,7 @@ import { calculateTopPercentileFromDistribution, getRatingAudienceLabel, getRatingDistributionQuery, + parsePreferredRolesText, } from './MemberRatingCard.utils' describe('calculateTopPercentileFromDistribution', () => { @@ -116,3 +117,20 @@ describe('getRatingDistributionQuery', () => { }) }) }) + +describe('parsePreferredRolesText', () => { + it('splits preferred roles on supported separators', () => { + expect(parsePreferredRolesText('Designer\nFront-End Developer, Back-End Developer; Data Scientist')) + .toEqual([ + 'Designer', + 'Front-End Developer', + 'Back-End Developer', + 'Data Scientist', + ]) + }) + + it('keeps slash-separated role labels intact', () => { + expect(parsePreferredRolesText('Cybersecurity Analyst / Security Engineer')) + .toEqual(['Cybersecurity Analyst / Security Engineer']) + }) +}) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts index 599c219a3..3023f41d1 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts @@ -57,6 +57,20 @@ const getFiniteNumber = (value: unknown): number | undefined => ( typeof value === 'number' && Number.isFinite(value) ? value : undefined ) +/** + * Splits preferred role text into display-ready role labels. + * + * @param {string | undefined} preferredRolesText - Text from the profile preferred roles field. + * @returns {string[]} Cleaned role labels for the compact profile role list. + */ +export const parsePreferredRolesText = (preferredRolesText?: string): string[] => ( + (preferredRolesText ?? '') + .split(/[\n,;\u00b7\u2022]+/) + .map((role: string) => role.trim() + .replace(/^[-*]\s+/, '')) + .filter(Boolean) +) + /** * Parses the stats distribution API response into sorted rating ranges. * diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/ModifyPreferredRolesModal.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/ModifyPreferredRolesModal.module.scss new file mode 100644 index 000000000..22e306361 --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/ModifyPreferredRolesModal.module.scss @@ -0,0 +1,17 @@ +@import '@libs/ui/styles/includes'; + +.modalButtons { + display: flex; + justify-content: space-between; + width: 100%; +} + +.editForm { + :global(.input-wrapper) { + margin-bottom: $sp-4; + } +} + +.formError { + color: $red-100; +} diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/ModifyPreferredRolesModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/ModifyPreferredRolesModal.tsx new file mode 100644 index 000000000..edbefeffb --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/ModifyPreferredRolesModal.tsx @@ -0,0 +1,158 @@ +import { ChangeEvent, Dispatch, FC, SetStateAction, useEffect, useState } from 'react' +import { toast } from 'react-toastify' + +import { + updateOrCreateMemberTraitsAsync, + UserProfile, + UserTrait, + UserTraitCategoryNames, + UserTraitIds, +} from '~/libs/core' +import { BaseModal, Button, InputMultiselect, InputMultiselectOption } from '~/libs/ui' +import { preferredRoleOptions } from '~/libs/shared/lib/components/modify-open-to-work-modal' + +import { + getOpenToWorkWithoutPreferredRoles, + getPreferredRolesValues, +} from '../../../../lib' + +import styles from './ModifyPreferredRolesModal.module.scss' + +interface ModifyPreferredRolesModalProps { + memberPersonalizationTraitsData: UserTrait[] | undefined + onClose: () => void + onSave: () => void + profile: UserProfile +} + +/** + * Renders the modal used to edit the independent preferred roles profile field. + * + * @param {ModifyPreferredRolesModalProps} props - Profile, personalization data, and modal callbacks. + * @returns {JSX.Element} A modal with a preferred roles autocomplete multiselect and save/cancel actions. + */ +const ModifyPreferredRolesModal: FC = (props: ModifyPreferredRolesModalProps) => { + const [preferredRoles, setPreferredRoles]: [ + string[], + Dispatch> + ] = useState(getPreferredRolesValues(props.memberPersonalizationTraitsData)) + + const [isSaving, setIsSaving]: [boolean, Dispatch>] + = useState(false) + + const [isFormChanged, setIsFormChanged]: [boolean, Dispatch>] + = useState(false) + + const [formSaveError, setFormSaveError]: [ + string | undefined, + Dispatch> + ] = useState() + + useEffect(() => { + setPreferredRoles(getPreferredRolesValues(props.memberPersonalizationTraitsData)) + setIsFormChanged(false) + }, [props.memberPersonalizationTraitsData]) + + function handlePreferredRolesChange(event: ChangeEvent): void { + const options = (event.target as unknown as { value: InputMultiselectOption[] }).value + + setPreferredRoles(options.map(option => option.value)) + setIsFormChanged(true) + } + + async function fetchPreferredRoles(query: string): Promise { + if (!query) { + return preferredRoleOptions + } + + const normalizedQuery = query.toLowerCase() + return preferredRoleOptions.filter(option => { + const normalizedLabel = option.label?.toString() + .toLowerCase() + + return normalizedLabel?.includes(normalizedQuery) + }) + } + + function handlePreferredRolesSave(): void { + const existing = props.memberPersonalizationTraitsData?.[0] || {} + const personalizationItem: UserTrait = { + ...existing, + preferredRoles, + } + + if (existing.openToWork) { + personalizationItem.openToWork = getOpenToWorkWithoutPreferredRoles(existing.openToWork) + } + + setIsSaving(true) + setFormSaveError(undefined) + + updateOrCreateMemberTraitsAsync(props.profile.handle, [{ + categoryName: UserTraitCategoryNames.personalization, + traitId: UserTraitIds.personalization, + traits: { + data: [personalizationItem], + }, + }]) + .then(() => { + toast.success('Preferred roles updated successfully.', { position: toast.POSITION.BOTTOM_RIGHT }) + props.onSave() + }) + .catch((error: any) => { + toast.error('Failed to update your preferred roles.', { position: toast.POSITION.BOTTOM_RIGHT }) + setIsSaving(false) + setFormSaveError(error.message || error) + }) + } + + return ( + +
    + )} + > +
    + preferredRoles.includes(option.value), + )} + /> + + + { + formSaveError && ( +
    + {formSaveError} +
    + ) + } + + ) +} + +export default ModifyPreferredRolesModal diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/index.ts b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/index.ts new file mode 100644 index 000000000..4dcba3acc --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/index.ts @@ -0,0 +1 @@ +export { default as ModifyPreferredRolesModal } from './ModifyPreferredRolesModal' diff --git a/src/apps/profiles/src/member-profile/profile-header/OpenForGigsModifyModal/OpenForGigsModifyModal.tsx b/src/apps/profiles/src/member-profile/profile-header/OpenForGigsModifyModal/OpenForGigsModifyModal.tsx index 5d56b5030..2c8e0a6df 100644 --- a/src/apps/profiles/src/member-profile/profile-header/OpenForGigsModifyModal/OpenForGigsModifyModal.tsx +++ b/src/apps/profiles/src/member-profile/profile-header/OpenForGigsModifyModal/OpenForGigsModifyModal.tsx @@ -16,6 +16,8 @@ import { import OpenToWorkForm, { validateOpenToWork } from '~/libs/shared/lib/components/modify-open-to-work-modal/ModifyOpenToWorkModal' +import { getOpenToWorkWithoutPreferredRoles } from '../../../lib' + import styles from './OpenForGigsModifyModal.module.scss' interface OpenForGigsModifyModalProps { @@ -36,7 +38,6 @@ const OpenForGigsModifyModal: FC = (props: OpenForG const [formValue, setFormValue] = useState({ availability: undefined, availableForGigs: props.openForWork, - preferredRoles: [], }) const [submitAttempted, setSubmitAttempted] = useState(false) @@ -58,7 +59,6 @@ const OpenForGigsModifyModal: FC = (props: OpenForG ...prev, availability: openToWorkItem?.availability, availableForGigs: props.openForWork, - preferredRoles: openToWorkItem?.preferredRoles ?? [], })) }, [ memberPersonalizationTraits, @@ -91,9 +91,8 @@ const OpenForGigsModifyModal: FC = (props: OpenForG const personalizationData = [{ ...existing, openToWork: { - ...(existing.openToWork || {}), + ...getOpenToWorkWithoutPreferredRoles(existing.openToWork || {}), availability: formValue.availability, - preferredRoles: formValue.preferredRoles, }, }] @@ -101,7 +100,7 @@ const OpenForGigsModifyModal: FC = (props: OpenForG // Update availableForGigs in member profile updateMemberProfile(props.profile.handle, { availableForGigs: formValue.availableForGigs }), - // Update personalization trait for availability & preferredRoles + // Update personalization trait for availability. updateOrCreateMemberTraitsAsync(props.profile.handle, [{ categoryName: UserTraitCategoryNames.personalization, traitId: UserTraitIds.personalization, diff --git a/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.tsx b/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.tsx index 9ae99e3c9..df932d6db 100644 --- a/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.tsx +++ b/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.tsx @@ -19,7 +19,7 @@ import { Tooltip } from '~/libs/ui' import { AddButton, EditMemberPropertyBtn } from '../../components' import { EDIT_MODE_QUERY_PARAM, profileEditModes } from '../../config' -import { formatRoleList, getAvailabilityLabel, getPreferredRoleLabels } from '../../lib' +import { getAvailabilityLabel } from '../../lib' import { OpenForGigs } from './OpenForGigs' import { ModifyMemberNameModal } from './ModifyMemberNameModal' @@ -190,45 +190,19 @@ const ProfileHeader: FC = (props: ProfileHeaderProps) => { if ( !hasOpenToWork || !props.profile.availableForGigs - || (openToWorkItem.preferredRoles?.length === 0 && openToWorkItem.availability === undefined)) return <> + || !openToWorkItem.availability) return <> const availabilityLabel = getAvailabilityLabel(openToWorkItem.availability) - const roleLabels = getPreferredRoleLabels(openToWorkItem.preferredRoles) - const MAX_VISIBLE_ROLES = 5 - const visibleRoles = roleLabels.slice(0, MAX_VISIBLE_ROLES) - const hasMoreRoles = roleLabels.length > MAX_VISIBLE_ROLES - const tooltipContent = roleLabels.join(', ') - - const rolesContent = ( - - as - {' '} - - {formatRoleList(visibleRoles)} - {hasMoreRoles && '…'} - - - ) - const shouldShowTooltip = openToWorkItem.preferredRoles?.length > 5 + if (!availabilityLabel) return <> return (

    Interested in {' '} - {openToWorkItem.availability && {availabilityLabel}} + {availabilityLabel} {' '} roles - {' '} - {openToWorkItem.preferredRoles?.length > 0 && ( - shouldShowTooltip ? ( - - {rolesContent} - - ) : ( - rolesContent - ) - )}

    ) } diff --git a/src/libs/shared/lib/components/modify-open-to-work-modal/ModifyOpenToWorkModal.tsx b/src/libs/shared/lib/components/modify-open-to-work-modal/ModifyOpenToWorkModal.tsx index cf109f6fa..e10f32336 100644 --- a/src/libs/shared/lib/components/modify-open-to-work-modal/ModifyOpenToWorkModal.tsx +++ b/src/libs/shared/lib/components/modify-open-to-work-modal/ModifyOpenToWorkModal.tsx @@ -1,6 +1,6 @@ -import { ChangeEvent, FC, useCallback } from 'react' +import { ChangeEvent, FC } from 'react' -import { InputMultiselect, InputMultiselectOption, InputRadio, InputSelect } from '~/libs/ui' +import { InputRadio, InputSelect } from '~/libs/ui' import styles from './ModifyOpenToWorkModal.module.scss' @@ -9,7 +9,6 @@ export type AvailabilityType = 'FULL_TIME' | 'PART_TIME' export interface OpenToWorkData { availableForGigs: boolean | null availability?: AvailabilityType - preferredRoles?: string[] } interface OpenToWorkFormProps { @@ -21,11 +20,11 @@ interface OpenToWorkFormProps { } export const availabilityOptions = [ - { label: 'Full-time', value: 'FULL_TIME' }, - { label: 'Part-time', value: 'PART_TIME' }, + { label: 'Full-Time', value: 'FULL_TIME' }, + { label: 'Part-Time', value: 'PART_TIME' }, ] -export const preferredRoleOptions: InputMultiselectOption[] = [ +export const preferredRoleOptions: Array<{ label: string, value: string }> = [ { label: 'AI / ML Engineer', value: 'AI_ML_ENGINEER' }, { label: 'Data Scientist / Data Engineer', value: 'DATA_SCIENTIST_ENGINEER' }, { label: 'Cybersecurity Analyst / Security Engineer', value: 'CYBERSECURITY_ENGINEER' }, @@ -49,10 +48,6 @@ export const validateOpenToWork = (value: OpenToWorkData): { [key: string]: stri errors.availability = 'Availability is required.' } - if (!value.preferredRoles || value.preferredRoles.length === 0) { - errors.preferredRoles = 'Select at least one preferred role.' - } - return errors } @@ -73,29 +68,6 @@ const OpenToWorkForm: FC = (props: OpenToWorkFormProps) => }) } - const handleRolesChange = useCallback((e: ChangeEvent) => { - const options = (e.target as unknown as { value: InputMultiselectOption[] }).value - - props.onChange({ - ...props.value, - preferredRoles: options.map(o => o.value), - }) - }, [props]) - - const fetchPreferredRoles = useCallback(async (query: string): Promise => { - if (!query) { - return preferredRoleOptions - } - - const normalizedQuery = query.toLowerCase() - return preferredRoleOptions.filter(option => { - const normalizedLabel = option.label?.toString() - .toLowerCase() - - return normalizedLabel?.includes(normalizedQuery) - }) - }, []) - return (
    @@ -134,22 +106,6 @@ const OpenToWorkForm: FC = (props: OpenToWorkFormProps) => error={props.showErrors ? props.formErrors?.availability : undefined} /> - props.value.preferredRoles?.includes(option.value), - )} - onChange={handleRolesChange} - disabled={props.disabled} - dirty={props.showErrors} - error={props.showErrors ? props.formErrors?.preferredRoles : undefined} - /> )}
    From 601919c4ab4d4fdad5c0bbaa98977353d41fb265 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 5 Jun 2026 12:04:41 +1000 Subject: [PATCH 08/73] PM-5261: Keep rating position details together What was broken The rating info popup rendered the pyramid graphic as its own summary cell between overall rating and position, which separated the triangle from the position data. Root cause The summary grid defined three columns and rendered the pyramid as a standalone grid item with its own divider, instead of grouping it with the position metric. What was changed Moved the pyramid markup inside the position summary cell and updated the summary grid styles so the position cell contains both the pyramid and position text, including responsive behavior. Any added/updated tests Added a MemberRatingInfoModal rendering test that verifies the position summary cell contains both the position data and the pyramid graphic. --- .../MemberRatingInfoModal.module.scss | 29 ++++--- .../MemberRatingInfoModal.spec.tsx | 78 +++++++++++++++++++ .../MemberRatingInfoModal.tsx | 73 +++++++++-------- 3 files changed, 134 insertions(+), 46 deletions(-) create mode 100644 src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss index 7ca5d6e92..5ad9294b1 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss @@ -64,12 +64,12 @@ border: 1px solid $black-10; border-radius: 6px; display: grid; - grid-template-columns: minmax(0, 1fr) 126px minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr); min-height: 96px; overflow: hidden; @include ltesm { - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1fr); } } @@ -93,14 +93,27 @@ } .positionMetric { + align-items: center; border-left: 1px solid $black-10; + flex-direction: row; + gap: $sp-6; + justify-content: flex-start; @include ltesm { - border-left: 1px solid $black-10; + border-left: 0; border-top: 1px solid $black-10; + gap: $sp-4; + grid-column: 1 / -1; } } +.positionDetails { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + .summaryLabel { color: $black-80; font-family: $font-roboto; @@ -135,18 +148,10 @@ .tierPyramid { align-items: center; - border-left: 1px solid $black-10; display: flex; - flex-direction: column; + flex: 0 0 76px; justify-content: center; min-width: 0; - padding: $sp-2 0; - - @include ltesm { - border-left: 0; - border-top: 1px solid $black-10; - padding: $sp-4 0; - } } .tierPyramidSvg { diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx new file mode 100644 index 000000000..437a91092 --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx @@ -0,0 +1,78 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import type { PropsWithChildren } from 'react' +import { render, screen, within } from '@testing-library/react' + +import type { UserProfile } from '~/libs/core' + +import MemberRatingInfoModal from './MemberRatingInfoModal' + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn(() => '#616BD5'), +}), { + virtual: true, +}) + +jest.mock('~/libs/ui', () => ({ + BaseModal: (props: PropsWithChildren<{ + open?: boolean + title?: JSX.Element + }>): JSX.Element => ( + props.open ? ( +
    + {props.title} + {props.children} +
    + ) : <> + ), +}), { + virtual: true, +}) + +jest.mock('../../../../lib', () => ({ + numberToFixed: (value: number | string, digits: number = 2): string => Number(value) + .toFixed(digits), +})) + +describe('MemberRatingInfoModal', () => { + it('keeps the pyramid graphic in the position summary cell', () => { + render( + , + ) + + const positionSummary = screen.getByTestId('rating-position-summary') + + expect(within(positionSummary) + .getByText('Position')) + .toBeInTheDocument() + expect(within(positionSummary) + .getByText(/TOP\s+15%/)) + .toBeInTheDocument() + expect(positionSummary.querySelector('svg')) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx index 6bf2e32f8..53b830a3f 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx @@ -327,41 +327,46 @@ const MemberRatingInfoModal: FC = (props: MemberRati {getRatingTierName(props.rating)}
    - +
    + -
    - Position - - TOP - {' '} - {percentileLabel} - {percentileLabel === '--' ? '' : '%'} - - - of - {' '} - {props.audienceLabel.toLowerCase()} - +
    + Position + + TOP + {' '} + {percentileLabel} + {percentileLabel === '--' ? '' : '%'} + + + of + {' '} + {props.audienceLabel.toLowerCase()} + +
    From 1328d4897a5fe8437e424327ffffb838ba6e73e9 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 5 Jun 2026 12:14:34 +1000 Subject: [PATCH 09/73] PM-5260: Fix rating popup typography What was broken The AI rating comparison popup used smaller Roboto text for the title, body copy, summary labels, percentile value, distribution heading, and legend labels than the PM-5260 design required. The distribution heading could also inherit uppercase styling. Root cause (if identifiable) The modal stylesheet carried the initial PM-5261 typography values instead of the updated Figma and Jira font sizes and Barlow title treatment. What was changed Updated the rating info modal typography to use Barlow 22px for the title, 16px body, summary, and section text, 32px percentile text, sentence-case distribution heading styling, and larger legend range and tier labels. Any added/updated tests Updated the MemberRatingInfoModal spec to assert the distribution heading renders in sentence case. --- .../MemberRatingInfoModal.module.scss | 33 ++++++++++--------- .../MemberRatingInfoModal.spec.tsx | 2 ++ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss index 5ad9294b1..ef6da8731 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss @@ -37,10 +37,10 @@ .title { color: $black-100; - font-family: $font-roboto; - font-size: 16px; + font-family: $font-barlow; + font-size: 22px; font-weight: $font-weight-bold; - line-height: 24px; + line-height: 28px; margin: 0; padding-right: $sp-8; } @@ -54,8 +54,8 @@ .description { color: $black-100; font-family: $font-roboto; - font-size: 14px; - line-height: 21px; + font-size: 16px; + line-height: 24px; margin: 0; } @@ -117,9 +117,9 @@ .summaryLabel { color: $black-80; font-family: $font-roboto; - font-size: 12px; - font-weight: $font-weight-medium; - line-height: 16px; + font-size: 16px; + font-weight: $font-weight-bold; + line-height: 22px; } .ratingValue { @@ -132,9 +132,9 @@ .positionValue { font-family: $font-barlow-condensed; - font-size: 28px; + font-size: 32px; font-weight: $font-weight-medium; - line-height: 32px; + line-height: 34px; text-transform: uppercase; white-space: nowrap; } @@ -163,10 +163,11 @@ .sectionTitle { color: $black-100; font-family: $font-roboto; - font-size: 14px; + font-size: 16px; font-weight: $font-weight-bold; - line-height: 20px; + line-height: 22px; margin: $sp-1 0 0; + text-transform: none; } .chart { @@ -339,14 +340,14 @@ .legendRange { color: $black-100; font-family: $font-roboto; - font-size: 11px; + font-size: 12px; font-weight: $font-weight-bold; - line-height: 14px; + line-height: 16px; } .legendLabel { color: $black-80; font-family: $font-roboto; - font-size: 10px; - line-height: 14px; + font-size: 11px; + line-height: 15px; } diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx index 437a91092..eb109e2a2 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx @@ -74,5 +74,7 @@ describe('MemberRatingInfoModal', () => { .toBeInTheDocument() expect(positionSummary.querySelector('svg')) .toBeInTheDocument() + expect(screen.getByText('Where Emily ranks in the distribution')) + .toBeInTheDocument() }) }) From 323ef4a3d69d0a88f69551859825862f449b2c8a Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 5 Jun 2026 12:24:30 +1000 Subject: [PATCH 10/73] PM-5258: Format award badge tooltips What was broken Award icons on the compact profile Awards section used native browser title tooltips, so hover text rendered with inconsistent default styling. Root cause (if identifiable) CommunityAwards assigned badge names through the title attribute instead of the shared Tooltip component used elsewhere. What was changed Wrapped each award button with the shared Tooltip component and removed the native title attribute so the badge name appears in the formatted tooltip. Any added/updated tests Added a CommunityAwards regression test asserting award buttons no longer render title attributes and pass the badge name to the tooltip wrapper. --- .../community-awards/CommunityAwards.spec.tsx | 86 +++++++++++++++++++ .../community-awards/CommunityAwards.tsx | 29 ++++--- 2 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 src/apps/profiles/src/member-profile/community-awards/CommunityAwards.spec.tsx diff --git a/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.spec.tsx b/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.spec.tsx new file mode 100644 index 000000000..6fa698720 --- /dev/null +++ b/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.spec.tsx @@ -0,0 +1,86 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import type { PropsWithChildren, ReactNode } from 'react' +import { render, screen } from '@testing-library/react' + +import { useMemberBadges, type UserBadge, type UserBadgesResponse, type UserProfile } from '~/libs/core' + +import CommunityAwards from './CommunityAwards' + +jest.mock('~/libs/core', () => ({ + useMemberBadges: jest.fn(), +}), { + virtual: true, +}) + +jest.mock('~/libs/ui', () => ({ + Tooltip: (props: PropsWithChildren<{ content?: ReactNode }>): JSX.Element => ( +
    + {props.children} +
    + ), +}), { + virtual: true, +}) + +jest.mock('../../components', () => ({ + MemberBadgeModal: (): JSX.Element =>
    , +})) + +const mockUseMemberBadges = useMemberBadges as jest.MockedFunction + +function createBadge(badgeName: string): UserBadge { + return { + awarded_at: new Date('2026-06-04T00:00:00.000Z'), + awarded_by: 'admin', + org_badge: { + active: true, + badge_description: 'Awarded for AI profile work.', + badge_image_url: 'https://example.com/ai-rookie.svg', + badge_name: badgeName, + badge_status: 'active', + id: 'badge-1', + organization_id: 'topcoder', + orgranization: { + id: 'topcoder', + name: 'Topcoder', + }, + tags_id_tags: [], + }, + org_badge_id: 'badge-1', + user_handle: 'tester', + user_id: '123', + } +} + +describe('CommunityAwards', () => { + beforeEach(() => { + mockUseMemberBadges.mockReset() + }) + + it('renders awards with formatted tooltips instead of native title tooltips', () => { + const memberBadges: UserBadgesResponse = { + count: 1, + rows: [createBadge('AI Rookie')], + } + + mockUseMemberBadges.mockReturnValue(memberBadges) + + render() + + const awardButton = screen.getByRole('button', { + name: 'View AI Rookie award details', + }) + + expect(awardButton) + .not + .toHaveAttribute('title') + expect(screen.getByTestId('award-tooltip')) + .toHaveAttribute('data-tooltip-content', 'AI Rookie') + expect(mockUseMemberBadges) + .toHaveBeenCalledWith(123, { limit: 500 }) + }) +}) diff --git a/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.tsx b/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.tsx index 613c38c79..c67f08155 100644 --- a/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.tsx +++ b/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.tsx @@ -2,6 +2,7 @@ import { Dispatch, FC, SetStateAction, useCallback, useEffect, useState } from ' import { bind } from 'lodash' import { useMemberBadges, UserBadge, UserBadgesResponse, UserProfile } from '~/libs/core' +import { Tooltip } from '~/libs/ui' import { MemberBadgeModal } from '../../components' @@ -59,20 +60,24 @@ const CommunityAwards: FC = (props: CommunityAwardsProps)
    { visibleBadges.map(badge => ( - + + )) }
    From a4bd63a6a65b6633fc58d255daf65e01fad57ced Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 5 Jun 2026 12:38:18 +1000 Subject: [PATCH 11/73] PM-5257: Align mobile rating card data What was broken The mobile profile rating card laid out the rating, percentile, audience label, and help link across the same multi-column grid used at wider sizes, so the rating data did not align as one readable stack. Root cause The small-screen breakpoint kept the three-column grid and right-aligned the help link instead of overriding the rating section to a single aligned mobile column. What was changed Updated the mobile MemberRatingCard styles to use one grid column, keep the percentile audience label aligned with the rating data, and place the help action at the same left edge. Any added/updated tests No tests were added because this is a scoped SCSS layout fix. Ran the existing profile rating tests for the touched area. --- .../MemberRatingCard.module.scss | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss index 03702122d..3a2153fdc 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss @@ -95,15 +95,23 @@ } @include ltesm { - grid-template-columns: 52px 104px auto; + grid-template-columns: 1fr; column-gap: $sp-3; + row-gap: $sp-1; + justify-content: start; justify-items: stretch; width: 300px; + .percentileWrap { + .name { + margin-left: 0; + } + } + .link { - grid-column: -2 / -1; - justify-self: end; - margin-top: $sp-1; + grid-column: 1; + justify-self: start; + margin-top: 0; } } } From 951c9307b869e145ae44f0c404c1eb8e111f1342 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 5 Jun 2026 12:48:56 +1000 Subject: [PATCH 12/73] PM-5253: Fix rating card typography What was broken The compact profile rating card rendered the Rating/Data Scientists labels with the inherited browser font, the Top percentage pill at 13px, and the What is this? link at 11px instead of the Figma typography. Root cause (if identifiable) The card SCSS did not explicitly set Roboto on the small rating labels and used outdated font sizes for the percentile pill and help link. What was changed Updated the MemberRatingCard module styles so rating labels use Roboto, the Top percentage pill is 14px, and the What is this? link is 12px. Any added/updated tests No tests were added because this is a scoped CSS typography adjustment. Existing MemberRatingCard tests were run to confirm the profile rating-card area still passes. --- .../about-me/MemberRatingCard/MemberRatingCard.module.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss index 3a2153fdc..94e9357d9 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss @@ -48,6 +48,7 @@ .name { color: rgba($tc-white, 0.84); + font-family: $font-roboto; font-size: 12px; line-height: 14px; } @@ -56,7 +57,7 @@ background: rgba($tc-white, 0.06); border-radius: 2px; font-family: $font-roboto; - font-size: 13px; + font-size: 14px; font-weight: $font-weight-bold; line-height: 16px; padding: 2px $sp-2; @@ -79,7 +80,7 @@ justify-self: end; margin-top: $sp-1; color: $tc-white; - font-size: 11px; + font-size: 12px; line-height: 14px; font-weight: $font-weight-medium; font-family: $font-roboto; From 75cc78d66c06a3929d649fadcafc3d2ad6de1fa9 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 5 Jun 2026 13:00:29 +1000 Subject: [PATCH 13/73] PM-5227: tighten profile preferred-role display What was broken The QA follow-up for the redesigned member profile showed the role line under the rating card should collapse after two role labels and show a "+ N more" affordance. The prior ai-ratings implementation displayed up to four roles and used generic "See more" copy. Root cause The earlier role renderer used a hard-coded four-role collapsed limit and did not format the hidden role count from the design. What was changed Added a documented display-state helper for preferred roles and wired MemberRatingCard to render two collapsed roles with "+ N more", while retaining the existing expanded "See less" behavior and prior trait parsing. Any added/updated tests Added MemberRatingCard utility tests for the two-role collapsed state, expanded state, and no-toggle state. Ran the focused profile-card spec, lint, and build. The full yarn test:no-watch command was also run, but it still fails in unrelated work, wallet, and engagement suites outside this change. --- .../MemberRatingCard/MemberRatingCard.tsx | 18 ++++----- .../MemberRatingCard.utils.spec.ts | 40 +++++++++++++++++++ .../MemberRatingCard.utils.ts | 32 +++++++++++++++ 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx index e6a071579..8948eebc7 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx @@ -17,6 +17,7 @@ import { getPreferredRolesText, numberToFixed } from '../../../lib' import { calculateTopPercentileFromDistribution, + getPreferredRolesDisplay, getRatingAudienceLabel, getRatingDistributionQuery, parsePreferredRolesText, @@ -78,6 +79,10 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp () => parsePreferredRolesText(preferredRolesText), [preferredRolesText], ) + const preferredRolesDisplay = useMemo( + () => getPreferredRolesDisplay(preferredRoles, arePreferredRolesExpanded), + [arePreferredRolesExpanded, preferredRoles], + ) function handleInfoModalClose(): void { setIsInfoModalOpen(false) @@ -105,34 +110,27 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp } function renderPreferredRoles(): JSX.Element { - const MAX_VISIBLE_PREFERRED_ROLES = 4 - if (preferredRoles.length === 0 && !canEditPreferredRoles) { return <> } - const visiblePreferredRoles = arePreferredRolesExpanded - ? preferredRoles - : preferredRoles.slice(0, MAX_VISIBLE_PREFERRED_ROLES) - const hasMorePreferredRoles = preferredRoles.length > MAX_VISIBLE_PREFERRED_ROLES - return (
    {preferredRoles.length > 0 && (
    - {visiblePreferredRoles.map((role: string) => ( + {preferredRolesDisplay.visibleRoles.map((role: string) => ( {role} ))} - {hasMorePreferredRoles && ( + {preferredRolesDisplay.toggleLabel && ( )}
    diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts index 3ef085171..d13b89f9f 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts @@ -2,6 +2,7 @@ import type { UserStats, UserStatsDistributionResponse } from '~/libs/core' import { calculateTopPercentileFromDistribution, + getPreferredRolesDisplay, getRatingAudienceLabel, getRatingDistributionQuery, parsePreferredRolesText, @@ -134,3 +135,42 @@ describe('parsePreferredRolesText', () => { .toEqual(['Cybersecurity Analyst / Security Engineer']) }) }) + +describe('getPreferredRolesDisplay', () => { + const preferredRoles = [ + 'Designer', + 'Front-End Developer', + 'Back-End Developer', + 'Data Scientist', + ] + + it('shows two roles and the hidden role count when collapsed', () => { + expect(getPreferredRolesDisplay(preferredRoles, false)) + .toEqual({ + hiddenCount: 2, + toggleLabel: '+ 2 more', + visibleRoles: [ + 'Designer', + 'Front-End Developer', + ], + }) + }) + + it('shows all roles and a collapse label when expanded', () => { + expect(getPreferredRolesDisplay(preferredRoles, true)) + .toEqual({ + hiddenCount: 0, + toggleLabel: 'See less', + visibleRoles: preferredRoles, + }) + }) + + it('omits the toggle when all roles fit in the compact list', () => { + expect(getPreferredRolesDisplay(['Designer', 'Front-End Developer'], false)) + .toEqual({ + hiddenCount: 0, + toggleLabel: undefined, + visibleRoles: ['Designer', 'Front-End Developer'], + }) + }) +}) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts index 3023f41d1..23f3a2d22 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts @@ -11,6 +11,14 @@ interface RatingDistributionRange { value: number } +export interface PreferredRolesDisplay { + hiddenCount: number + toggleLabel: string | undefined + visibleRoles: string[] +} + +const maxCollapsedPreferredRoles = 2 + const aiEngineeringTrackNames: Set = new Set([ 'AI', 'AI_ENGINEER', @@ -71,6 +79,30 @@ export const parsePreferredRolesText = (preferredRolesText?: string): string[] = .filter(Boolean) ) +/** + * Builds the compact preferred-role display state for the profile rating card. + * + * @param {string[]} preferredRoles - Parsed preferred role labels in display order. + * @param {boolean} areExpanded - Whether the compact list has been expanded by the user. + * @returns {PreferredRolesDisplay} Visible roles plus the toggle label and collapsed hidden count. + */ +export const getPreferredRolesDisplay = ( + preferredRoles: string[], + areExpanded: boolean, +): PreferredRolesDisplay => { + const hiddenCount = Math.max(preferredRoles.length - maxCollapsedPreferredRoles, 0) + + return { + hiddenCount: areExpanded ? 0 : hiddenCount, + toggleLabel: hiddenCount > 0 + ? (areExpanded ? 'See less' : `+ ${hiddenCount} more`) + : undefined, + visibleRoles: areExpanded + ? preferredRoles + : preferredRoles.slice(0, maxCollapsedPreferredRoles), + } +} + /** * Parses the stats distribution API response into sorted rating ranges. * From 2d447d50f840ad559749fc295f472acbb7cb9dc2 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 5 Jun 2026 13:26:23 +1000 Subject: [PATCH 14/73] PM-5230: Show latest profile rating What was broken The compact profile card rendered memberStats.maxRating.rating, which is the historical max rating. For users whose newest rated track had moved below an older max, the card did not match the latest rating shown in the track stats. Root cause (if identifiable) The card read the stats API maxRating summary directly instead of deriving the display value from rated track entries. What was changed Added a helper that selects the rated track/subtrack with the newest mostRecentEventDate across Development, Design, and Data Science stats, falling back to maxRating only when no rated track entry exists. The card now renders that latest profile rating. Any added/updated tests Added unit tests for latest-track selection, configured Data Science rating paths, and maxRating fallback. --- .../MemberRatingCard/MemberRatingCard.tsx | 6 +- .../MemberRatingCard.utils.spec.ts | 82 +++++++++++++ .../MemberRatingCard.utils.ts | 112 ++++++++++++++++++ 3 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts create mode 100644 src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx index 3ca7e39d0..43d3250cd 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames' import { useMemberStats, UserProfile, UserStats } from '~/libs/core' +import { getLatestProfileRating } from './MemberRatingCard.utils' import { MemberRatingInfoModal } from './MemberRatingInfoModal' import styles from './MemberRatingCard.module.scss' @@ -13,6 +14,7 @@ interface MemberRatingCardProps { const MemberRatingCard: FC = (props: MemberRatingCardProps) => { const memberStats: UserStats | undefined = useMemberStats(props.profile.handle) + const rating: number | undefined = useMemo(() => getLatestProfileRating(memberStats), [memberStats]) const [isInfoModalOpen, setIsInfoModalOpen]: [boolean, Dispatch>] = useState(false) @@ -45,11 +47,11 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp setIsInfoModalOpen(true) } - return memberStats?.maxRating?.rating ? ( + return rating !== undefined ? (
    -

    {memberStats?.maxRating?.rating}

    +

    {rating}

    Rating

    { diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts new file mode 100644 index 000000000..ae2b910c1 --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts @@ -0,0 +1,82 @@ +import type { UserStats } from '~/libs/core' + +import { getLatestProfileRating } from './MemberRatingCard.utils' + +describe('getLatestProfileRating', () => { + it('uses the newest rated track instead of the historical max rating', () => { + expect(getLatestProfileRating({ + DATA_SCIENCE: { + 'AI Engineering': { + mostRecentEventDate: 1000, + rank: { + rating: 840, + }, + }, + }, + DEVELOP: { + subTracks: [{ + mostRecentEventDate: 2000, + name: 'Challenge', + rank: { + rating: 748, + }, + }], + }, + maxRating: { + rating: 840, + ratingColor: '#9D9FA0', + subTrack: 'AI Engineering', + track: 'DATA_SCIENCE', + }, + } as unknown as UserStats)) + .toBe(748) + }) + + it('uses configured data science rating paths when they are newest', () => { + expect(getLatestProfileRating({ + DATA_SCIENCE: { + AI: { + mostRecentEventDate: 2000, + rank: { + rating: 840, + }, + }, + }, + DEVELOP: { + subTracks: [{ + mostRecentEventDate: 1000, + name: 'Challenge', + rank: { + rating: 748, + }, + }], + }, + maxRating: { + rating: 840, + ratingColor: '#9D9FA0', + subTrack: 'AI Engineering', + track: 'DATA_SCIENCE', + }, + } as unknown as UserStats)) + .toBe(840) + }) + + it('falls back to maxRating when no rated track entries are available', () => { + expect(getLatestProfileRating({ + DEVELOP: { + subTracks: [{ + mostRecentEventDate: 2000, + name: 'First2Finish', + rank: {}, + }], + }, + maxRating: { + rating: 1100, + ratingColor: '#9D9FA0', + subTrack: 'Challenge', + track: 'DEVELOP', + }, + } as unknown as UserStats)) + .toBe(1100) + }) +}) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts new file mode 100644 index 000000000..97c30ac63 --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts @@ -0,0 +1,112 @@ +import { UserStats } from '~/libs/core' + +interface RatingCandidate { + rating: number + ratingDate: number +} + +type StatsRecord = Record + +/** + * Returns a finite number from unknown API data when the value can be used for rating comparisons. + * + * @param {unknown} value - A raw API value that may or may not be numeric. + * @returns {number | undefined} The numeric value when it is finite, otherwise undefined. + */ +const getFiniteNumber = (value: unknown): number | undefined => ( + typeof value === 'number' && Number.isFinite(value) ? value : undefined +) + +/** + * Checks whether an unknown API value can be read as an object record. + * + * @param {unknown} value - A raw API value. + * @returns {boolean} True when the value is a non-array object record. + */ +const isStatsRecord = (value: unknown): value is StatsRecord => ( + typeof value === 'object' + && value !== null + && !Array.isArray(value) +) + +/** + * Builds a rating candidate from a stats object that includes rank.rating. + * + * The member stats API stores the current rating at `rank.rating` and the + * event timestamp at `mostRecentEventDate`. Entries without a finite rating + * are ignored because unrated subtracks should not drive the profile rating. + * + * @param {unknown} stats - Raw subtrack or rating-path stats from the member stats API. + * @returns {RatingCandidate | undefined} Rating and event date when the stats are rated. + */ +const getRatingCandidate = (stats: unknown): RatingCandidate | undefined => { + if (!isStatsRecord(stats) || !isStatsRecord(stats.rank)) { + return undefined + } + + const rating: number | undefined = getFiniteNumber(stats.rank.rating) + + if (rating === undefined) { + return undefined + } + + return { + rating, + ratingDate: getFiniteNumber(stats.mostRecentEventDate) ?? 0, + } +} + +/** + * Extracts rated candidates from a design or development subtrack list. + * + * @param {unknown} subTracks - Raw `subTracks` array from member stats. + * @returns {RatingCandidate[]} Rated subtracks available for profile rating selection. + */ +const getSubTrackRatingCandidates = (subTracks: unknown): RatingCandidate[] => ( + Array.isArray(subTracks) + ? subTracks + .map(getRatingCandidate) + .filter((candidate: RatingCandidate | undefined): candidate is RatingCandidate => candidate !== undefined) + : [] +) + +/** + * Extracts rated candidates from native and configured DATA_SCIENCE rating paths. + * + * @param {unknown} dataScienceStats - Raw DATA_SCIENCE stats from member stats. + * @returns {RatingCandidate[]} Rated data science paths available for profile rating selection. + */ +const getDataScienceRatingCandidates = (dataScienceStats: unknown): RatingCandidate[] => ( + isStatsRecord(dataScienceStats) + ? Object.values(dataScienceStats) + .map(getRatingCandidate) + .filter((candidate: RatingCandidate | undefined): candidate is RatingCandidate => candidate !== undefined) + : [] +) + +/** + * Returns the rating that should be shown on the compact profile rating card. + * + * The card should show the latest current rating from the user's rated tracks, + * not the historical maximum rating. `maxRating` is used only as a fallback + * when the stats payload does not include any rated track entries. + * + * @param {UserStats | undefined} memberStats - Raw member stats for the profile user. + * @returns {number | undefined} Latest current rating, or the max rating fallback when no track rating exists. + */ +export const getLatestProfileRating = (memberStats?: UserStats): number | undefined => { + const candidates: RatingCandidate[] = [ + ...getSubTrackRatingCandidates(memberStats?.DEVELOP?.subTracks), + ...getSubTrackRatingCandidates(memberStats?.DESIGN?.subTracks), + ...getDataScienceRatingCandidates(memberStats?.DATA_SCIENCE), + ] + + const latestCandidate: RatingCandidate | undefined = candidates.reduce(( + latest: RatingCandidate | undefined, + candidate: RatingCandidate, + ) => ( + latest === undefined || candidate.ratingDate > latest.ratingDate ? candidate : latest + ), undefined) + + return latestCandidate?.rating ?? memberStats?.maxRating?.rating +} From b06aecdb175ade5fba18175273dedd09e61a0140 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 9 Jun 2026 13:13:49 +1000 Subject: [PATCH 15/73] PM-5256: Fix challenge points typography What was broken The Challenge Points profile bar rendered the label at 18px in bold and the points total at 32px, which did not match the required typography. Root cause (if identifiable) The earlier Challenge Points implementation inherited oversized bar text and explicitly set the label to bold and the numeric value to 32px. What was changed Updated the Challenge Points bar stylesheet so the label renders at 16px with normal weight and the points total renders at 26px. Any added/updated tests No tests were added because this is a CSS-only typography adjustment. Existing profile specs were run for the surrounding profile area. --- .../MemberStatsBlock/MemberStatsBlock.module.scss | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss index 67e80a138..5e7cf1236 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss @@ -22,8 +22,8 @@ background: linear-gradient(90deg, #008A72, #005B86); color: $tc-white; font-family: $font-roboto; - font-size: 18px; - line-height: 24px; + font-size: 16px; + line-height: 22px; @include ltesm { flex-wrap: wrap; @@ -33,14 +33,14 @@ } .challengePointsLabel { - font-weight: $font-weight-bold; + font-weight: $font-weight-normal; } .challengePointsValue { font-family: $font-barlow-condensed; - font-size: 32px; + font-size: 26px; font-weight: $font-weight-medium; - line-height: 34px; + line-height: 28px; } .challengePointsMeta { From e29f6553158b03b49c701da23c7cd15811d82c9c Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 9 Jun 2026 13:31:49 +1000 Subject: [PATCH 16/73] PM-5274: Enlarge challenge points breakdown arrow What was broken The challenge points banner rendered the "View breakdown" chevron too small compared with the adjacent label and profile stats arrows. Root cause The breakdown trigger used the shared `icon-sm` class, which renders an 8px icon. What was changed Changed the breakdown chevron to use `icon-lg` so it renders at the larger profile action size. Any added/updated tests Added a MemberChallengePointsBar regression test that renders a breakdown-capable points bar and asserts the chevron uses `icon-lg` instead of `icon-sm`. --- .../MemberStatsBlock.spec.tsx | 77 +++++++++++++++++++ .../MemberStatsBlock/MemberStatsBlock.tsx | 2 +- 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.spec.tsx diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.spec.tsx b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.spec.tsx new file mode 100644 index 000000000..e8c4a00c7 --- /dev/null +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.spec.tsx @@ -0,0 +1,77 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import type { PropsWithChildren } from 'react' +import { render, screen, within } from '@testing-library/react' + +import type { UserProfile } from '~/libs/core' + +import { MemberChallengePointsBar } from './MemberStatsBlock' + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn(() => '#000000'), + useMemberStats: jest.fn(), +}), { + virtual: true, +}) + +jest.mock('~/libs/ui', () => ({ + BaseModal: (props: PropsWithChildren): JSX.Element =>
    {props.children}
    , + IconOutline: { + ChevronRightIcon: (props: { className?: string }): JSX.Element => ( + + ), + }, +}), { + virtual: true, +}) + +jest.mock('../../../member-profile/MemberProfile.context', () => ({ + useMemberProfileContext: jest.fn(() => ({ + statsRoute: jest.fn(), + })), +})) + +jest.mock('./MemberChallengePointsModal', () => jest.fn(() => '')) + +jest.mock('../../../lib', () => ({ + formatPlural: (count: number, label: string): string => `${label}${count === 1 ? '' : 's'}`, + WinnerIcon: (props: { className?: string }): JSX.Element => , +})) + +describe('MemberChallengePointsBar', () => { + it('renders the breakdown chevron with the larger icon size', () => { + render( + , + ) + + const breakdownButton = screen.getByRole('button', { + name: /view breakdown/i, + }) + const chevron = within(breakdownButton) + .getByTestId('breakdown-chevron') + + expect(chevron) + .toHaveClass('icon-lg') + expect(chevron) + .not + .toHaveClass('icon-sm') + }) +}) diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx index be35f4231..eb6e2bad1 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx @@ -148,7 +148,7 @@ const ChallengePointsBar: FC = props => ( type='button' > View breakdown - + )}
    From 2b532df9cd35337a26b92dd24b347e3ed859236b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 9 Jun 2026 13:42:28 +1000 Subject: [PATCH 17/73] PM-5277: Fix preferred role styling What was broken The preferred roles under the profile rating card used 16px Roboto styling, inserted a middle-dot separator between role labels, and sat too close to the member title below. Root cause The rating-card SCSS rendered preferred roles as wrapping inline items with a pseudo-element separator and reused the older green toggle styling. What was changed Updated the preferred role list to use 14px Nunito Sans at 500 weight, render roles as separate centered rows without the separator symbol, style the expand/collapse control with #0D61BF, and add bottom spacing before the profile title. Any added/updated tests No test files were changed. Ran the focused MemberRatingCard utility spec, the full non-watch Jest command, lint, and build. --- .../MemberRatingCard.module.scss | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss index 94e9357d9..a3fd52e51 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss @@ -123,7 +123,7 @@ color: $black-100; display: flex; justify-content: center; - margin: $sp-4 auto 0; + margin: $sp-4 auto $sp-5; max-width: 320px; position: relative; width: 100%; @@ -131,32 +131,31 @@ .preferredRolesList { display: flex; - flex-wrap: wrap; - font-size: 16px; - gap: $sp-1 0; - justify-content: center; - line-height: 24px; + align-items: center; + flex-direction: column; + font-family: "Nunito Sans", sans-serif; + font-size: 14px; + font-weight: $font-weight-medium; + gap: $sp-1; + line-height: 20px; text-align: center; } .preferredRole { - display: inline-flex; + display: block; white-space: normal; - - + .preferredRole::before { - content: '\00b7'; - margin: 0 $sp-2; - } } .preferredRolesToggle { - color: $turq-160; - font-size: 16px; - line-height: 24px; - margin-left: $sp-2; + color: $link-blue-dark; + font-family: "Nunito Sans", sans-serif; + font-size: 14px; + font-weight: $font-weight-medium; + line-height: 20px; white-space: nowrap; &:hover { + color: darken($link-blue-dark, 5); text-decoration: underline; } } From 96ec6f93a4ad270386d85e48bff83d0c9fbebc78 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 9 Jun 2026 14:43:26 +1000 Subject: [PATCH 18/73] Fixes for adding manual reviewer --- .../challenges/ChallengeEditorPage/README.md | 2 +- .../ReviewersField/HumanReviewTab.spec.tsx | 324 +++++++++++++++++- .../ReviewersField/HumanReviewTab.tsx | 150 ++++++-- 3 files changed, 445 insertions(+), 31 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 5adbef9d4..67f4a6824 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -63,7 +63,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `ChallengeScheduleSection`: schedule editor for challenge start and phase dates. It keeps the detected timezone above the controls, renders the `Start Date` label with the `Scheduled` and `Immediately` start-mode radios aligned to the end of that header row above the input with a green selected state, persists the selected start mode in challenge metadata so saved `/edit` and `/view` routes reopen with the correct radio state, recalculates root phase dates when the challenge start changes, honors a completed predecessor phase's actual end date when deriving successor schedule rows, and keeps completed phases' end-date and duration controls locked to match legacy work-manager behavior. `Task` challenges hide this editable section across create, edit, and read-only view routes to match legacy work-manager behavior. - `DesignWorkTypeField`: shown for Design + Challenge, with the legacy work-type options (`Application Front-End Design`, `Print/Presentation`, `Web Design`, `Widget or Mobile Screen Design`, `Wireframes`). The selected value is stored in challenge tags. - `FunChallengeField`: shown for `Marathon Match` type and remains editable after creation so the form can switch between fun-challenge and standard marathon-match fields. -- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. On the human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. +- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. On the human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. - `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and submission-visibility controls. - `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages. - `MaximumSubmissionsField`: non-visual compatibility field that rewrites the legacy `submissionLimit` metadata to the unlimited-only payload so design challenges no longer expose submission-cap controls. It defers dirtying that automatic normalization until the editor finishes its initial resource hydration, including the first render after asynchronously loaded challenge details arrive, which preserves copilot restoration before autosave/manual-save starts treating the metadata rewrite as a user change. diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx index f3f82ecb3..23a64dbc9 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx @@ -249,9 +249,13 @@ interface TestHarnessProps { defaultValues?: Partial initialScorecardErrorMessage?: string restoreStaleAdditionalMemberIds?: boolean + restoreStaleScorecardId?: boolean showAdditionalMemberIdsValue?: boolean showMemberValue?: boolean + showRoleValue?: boolean + showRoleValueIndex?: number showScorecardValue?: boolean + showScorecardValueIndex?: number } const baseDefaultValues: ChallengeEditorFormData = { @@ -343,6 +347,42 @@ const StaleAdditionalMemberIdsReporter = (): JSX.Element => { return
    {renderCountRef.current}
    } +/** + * Simulates form state briefly reporting the same stale scorecard after cleanup. + * + * @returns render-count marker used to detect runaway scorecard cleanup loops. + * @throws when the regression repeatedly clears and restores the same scorecard id. + */ +const StaleScorecardIdReporter = (): JSX.Element => { + const formContext = useReactHookFormContext() + const renderCountRef = useRef(0) + const scorecardId = useWatch({ + control: formContext.control, + name: 'reviewers.0.scorecardId', + }) as string | undefined + + renderCountRef.current += 1 + if (renderCountRef.current > 20) { + throw new Error('Scorecard cleanup looped') + } + + useEffect(() => { + if (scorecardId !== undefined) { + return + } + + formContext.setValue('reviewers.0.scorecardId', 'stale-scorecard', { + shouldDirty: false, + shouldValidate: false, + }) + }, [ + formContext, + scorecardId, + ]) + + return
    {renderCountRef.current}
    +} + const TestHarness = (props: TestHarnessProps): JSX.Element => { const formMethods = useForm({ defaultValues: { @@ -350,6 +390,8 @@ const TestHarness = (props: TestHarnessProps): JSX.Element => { ...props.defaultValues, }, }) + const roleValueIndex = props.showRoleValueIndex ?? 0 + const scorecardValueIndex = props.showScorecardValueIndex ?? 0 useEffect(() => { if (!props.initialScorecardErrorMessage) { @@ -371,6 +413,9 @@ const TestHarness = (props: TestHarnessProps): JSX.Element => { {props.restoreStaleAdditionalMemberIds ? : undefined} + {props.restoreStaleScorecardId + ? + : undefined} {props.showAdditionalMemberIdsValue ? (
    @@ -387,10 +432,17 @@ const TestHarness = (props: TestHarnessProps): JSX.Element => {
    ) : undefined} + {props.showRoleValue + ? ( +
    + {String(formMethods.watch(`reviewers.${roleValueIndex}.roleId` as never) || '')} +
    + ) + : undefined} {props.showScorecardValue ? (
    - {formMethods.watch('reviewers.0.scorecardId') || ''} + {String(formMethods.watch(`reviewers.${scorecardValueIndex}.scorecardId` as never) || '')}
    ) : undefined} @@ -772,6 +824,232 @@ describe('HumanReviewTab', () => { }) }) + it('adds single-round design reviewers on the approval phase when default metadata is stale', async () => { + mockedUseFetchChallengeTracks.mockReturnValue({ + tracks: [ + { + id: 'design-track', + name: 'Design', + track: 'DESIGN', + }, + ], + }) + mockedUseFetchChallengeTypes.mockReturnValue({ + challengeTypes: [ + { + id: 'challenge-type', + name: 'Challenge', + }, + ], + }) + mockedUseFetchResourceRoles.mockReturnValue({ + resourceRoles: [ + { + id: 'role-reviewer', + name: 'Reviewer', + }, + { + id: 'role-approver', + name: 'Approver', + }, + ], + }) + mockedFetchDefaultReviewers.mockResolvedValue([ + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'stale-review-phase', + roleId: 'role-reviewer', + scorecardId: 'scorecard-approval', + }, + ]) + mockedFetchScorecards.mockResolvedValue([ + { + id: 'scorecard-approval', + name: 'Approval scorecard', + type: 'Approval', + }, + ]) + + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('button', { name: 'Add reviewer' }) as HTMLButtonElement).disabled) + .toBe(false) + }) + + fireEvent.click(screen.getByRole('button', { name: 'Add reviewer' })) + + await waitFor(() => { + expect(screen.getByTestId('reviewers.0.phaseId') + .getAttribute('data-value')) + .toBe('approval') + }) + expect(screen.getByTestId('role-id-value').textContent) + .toBe('role-approver') + expect(screen.getByTestId('scorecard-id-value').textContent) + .toBe('scorecard-approval') + }) + + it('adds the missing approval reviewer when design default reviewers fail to load', async () => { + mockedUseFetchChallengeTracks.mockReturnValue({ + tracks: [ + { + id: 'design-track', + name: 'Design', + track: 'DESIGN', + }, + ], + }) + mockedUseFetchChallengeTypes.mockReturnValue({ + challengeTypes: [ + { + id: 'challenge-type', + name: 'Challenge', + }, + ], + }) + mockedUseFetchResourceRoles.mockReturnValue({ + resourceRoles: [ + { + id: 'role-screener', + name: 'Screener', + }, + { + id: 'role-reviewer', + name: 'Reviewer', + }, + { + id: 'role-approver', + name: 'Approver', + }, + ], + }) + mockedFetchDefaultReviewers.mockRejectedValue(new Error('Default reviewers unavailable')) + mockedFetchScorecards.mockResolvedValue([ + { + id: 'scorecard-screening', + name: 'Screening scorecard', + type: 'Screening', + }, + { + id: 'scorecard-review', + name: 'Review scorecard', + type: 'Review', + }, + { + id: 'scorecard-approval', + name: 'Approval scorecard', + type: 'Approval', + }, + ]) + + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('button', { name: 'Add reviewer' }) as HTMLButtonElement).disabled) + .toBe(false) + }) + + fireEvent.click(screen.getByRole('button', { name: 'Add reviewer' })) + + await waitFor(() => { + expect(screen.getByTestId('reviewers.2.phaseId') + .getAttribute('data-value')) + .toBe('approval') + }) + expect(screen.getByTestId('role-id-value').textContent) + .toBe('role-approver') + expect(screen.getByTestId('scorecard-id-value').textContent) + .toBe('') + expect(screen.getByTestId('reviewers.2.scorecardId') + .getAttribute('data-options')) + .toContain('Approval scorecard') + }) + it('caps assignment fields when closed-opportunity reviewer count is too large', async () => { render() @@ -1048,6 +1326,50 @@ describe('HumanReviewTab', () => { .not.toContain('315HuPeby34i2b') }) + it('does not loop when stale scorecard cleanup is reported again', async () => { + mockedFetchScorecards.mockResolvedValue([ + { + id: 'scorecard-review', + name: 'Review scorecard', + type: 'Review', + }, + ]) + + render( + , + ) + + await waitFor(() => { + expect(mockedFetchScorecards) + .toHaveBeenCalled() + }) + await waitFor(() => { + expect(Number(screen.getByTestId('stale-scorecard-renders').textContent)) + .toBeLessThanOrEqual(20) + }) + }) + it('clears the selected scorecard when the reviewer phase changes', async () => { mockedFetchScorecards.mockResolvedValue([ { diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx index 3ff47258d..f55a8177f 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx @@ -482,26 +482,102 @@ function applyHydratedReviewerAssignment(params: { } } -function getReviewerPhaseId( - defaultReviewer: DefaultReviewer | undefined, - phases: ChallengeEditorFormData['phases'], +/** + * Returns the stable id used by reviewer rows for a challenge phase. + * + * @param phase challenge phase row from the editor form. + * @returns the normalized phase id or `undefined` when the row has no id. + */ +function getChallengePhaseId( + phase: NonNullable[number] | undefined, ): string | undefined { - if (defaultReviewer?.phaseId) { - return defaultReviewer.phaseId - } + return normalizeText(phase?.phaseId) || normalizeText(phase?.id) || undefined +} +/** + * Returns the first phase that is valid for a newly added manual reviewer row. + * + * @param phases current challenge schedule phases. + * @returns a review phase id, another selectable reviewer phase id, or `undefined`. + */ +function getFallbackReviewerPhaseId( + phases: ChallengeEditorFormData['phases'], + assignedPhaseIds: Set = new Set(), +): string | undefined { if (!Array.isArray(phases) || !phases.length) { return undefined } + const isUnassignedSelectablePhase = ( + phase: NonNullable[number] | undefined, + ): boolean => { + const phaseId = getChallengePhaseId(phase) + + return !!phaseId + && !assignedPhaseIds.has(phaseId) + && isSelectableReviewerPhaseName(phase?.name, false) + } + const reviewPhase = phases.find(phase => ( - typeof phase?.name === 'string' - && phase.name - .toLowerCase() - .includes('review') + isUnassignedSelectablePhase(phase) + && normalizeKey(phase?.name) + .includes('review') + )) + const selectablePhase = phases.find(isUnassignedSelectablePhase) + const fallbackPhase = phases.find(phase => ( + isSelectableReviewerPhaseName(phase?.name, false) )) - return reviewPhase?.phaseId || reviewPhase?.id || phases[0]?.phaseId || phases[0]?.id + return getChallengePhaseId(reviewPhase) + || getChallengePhaseId(selectablePhase) + || getChallengePhaseId(fallbackPhase) + || getChallengePhaseId(phases[0]) +} + +function getReviewerPhaseId( + defaultReviewer: DefaultReviewer | undefined, + phases: ChallengeEditorFormData['phases'], +): string | undefined { + const defaultPhaseId = normalizeText(defaultReviewer?.phaseId) + + if (defaultPhaseId) { + const phaseRows = Array.isArray(phases) + ? phases + : [] + const hasMatchingPhase = !phaseRows.length + || phaseRows.some(phase => getChallengePhaseId(phase) === defaultPhaseId) + + if (hasMatchingPhase) { + return defaultPhaseId + } + } + + return getFallbackReviewerPhaseId(phases) +} + +/** + * Returns the next default reviewer row that maps to an unassigned selectable phase. + * + * @param params default reviewer metadata and current manual reviewer phase usage. + * @returns matching default reviewer metadata, or `undefined` when no unused default phase exists. + */ +function getNextDefaultReviewerForManualRow(params: { + assignedPhaseIds: Set + defaultReviewers: DefaultReviewer[] + phaseNameById: Map + phases: ChallengeEditorFormData['phases'] +}): DefaultReviewer | undefined { + return params.defaultReviewers.find(defaultReviewer => { + if (!isMemberReviewer(defaultReviewer)) { + return false + } + + const phaseId = getReviewerPhaseId(defaultReviewer, params.phases) + + return !!phaseId + && !params.assignedPhaseIds.has(phaseId) + && isSelectableReviewerPhaseName(params.phaseNameById.get(phaseId), false) + }) } /** @@ -725,7 +801,7 @@ export const HumanReviewTab: FC = () => { const [loadError, setLoadError] = useState() const autoBackfilledReviewerTypesRef = useRef>({}) const trimmedAdditionalMemberIdsRef = useRef>({}) - const validatedScorecardSelectionsRef = useRef>({}) + const reconciledScorecardSelectionsRef = useRef>({}) const challengeId = useWatch({ control: formContext.control, @@ -1138,36 +1214,36 @@ export const HumanReviewTab: FC = () => { const scorecardFieldName = `reviewers.${fieldIndex}.scorecardId` const selectedScorecardId = normalizeText(reviewer.scorecardId) + const scorecardSelectionKey = `${normalizeText(reviewer.phaseId)}:${selectedScorecardId}` if (!selectedScorecardId) { - delete validatedScorecardSelectionsRef.current[scorecardFieldName] + if (!reconciledScorecardSelectionsRef.current[scorecardFieldName]?.startsWith('invalid:')) { + delete reconciledScorecardSelectionsRef.current[scorecardFieldName] + } + return } const hasSelectedScorecard = getAvailableScorecardsForReviewer(reviewer) .some(scorecard => hasSameNormalizedText(scorecard.id, selectedScorecardId)) if (hasSelectedScorecard) { - formContext.clearErrors(scorecardFieldName as any) - - if (validatedScorecardSelectionsRef.current[scorecardFieldName] === selectedScorecardId) { + const selectedScorecardKey = `valid:${scorecardSelectionKey}` + if (reconciledScorecardSelectionsRef.current[scorecardFieldName] === selectedScorecardKey) { return } - validatedScorecardSelectionsRef.current[scorecardFieldName] = selectedScorecardId + reconciledScorecardSelectionsRef.current[scorecardFieldName] = selectedScorecardKey - // Mirror the manual re-selection path so stale scorecard validation clears. - formContext.setValue( - scorecardFieldName as any, - selectedScorecardId, - { - shouldDirty: false, - shouldValidate: true, - }, - ) + formContext.clearErrors(scorecardFieldName as any) + + return + } + const selectedScorecardKey = `invalid:${scorecardSelectionKey}` + if (reconciledScorecardSelectionsRef.current[scorecardFieldName] === selectedScorecardKey) { return } - delete validatedScorecardSelectionsRef.current[scorecardFieldName] + reconciledScorecardSelectionsRef.current[scorecardFieldName] = selectedScorecardKey formContext.setValue( scorecardFieldName as any, undefined, @@ -1660,17 +1736,31 @@ export const HumanReviewTab: FC = () => { ) const addReviewer = useCallback((): void => { - const defaultReviewer = defaultReviewers.find(reviewer => isMemberReviewer(reviewer)) + const assignedPhaseIds = new Set( + reviewerRows + .map(reviewer => normalizeText(reviewer.phaseId)) + .filter(Boolean), + ) + const defaultReviewer = getNextDefaultReviewerForManualRow({ + assignedPhaseIds, + defaultReviewers, + phaseNameById, + phases, + }) const reviewerFromDefaults = mapDefaultReviewerToReviewer( defaultReviewer, phases, ) + const phaseId = (defaultReviewer ? reviewerFromDefaults.phaseId : undefined) + || getFallbackReviewerPhaseId(phases, assignedPhaseIds) + const roleIdForResolvedPhase = resolveRoleIdForPhase(phaseId) formContext.setValue('reviewers', [ ...allReviewerRows, { ...reviewerFromDefaults, - roleId: reviewerFromDefaults.roleId || resolveRoleIdForPhase(reviewerFromDefaults.phaseId), + phaseId, + roleId: roleIdForResolvedPhase || reviewerFromDefaults.roleId, }, ], { shouldDirty: true, @@ -1680,7 +1770,9 @@ export const HumanReviewTab: FC = () => { defaultReviewers, allReviewerRows, formContext, + phaseNameById, phases, + reviewerRows, resolveRoleIdForPhase, ]) From 1dfdfb3df2fb56ff75bf440abacf1cad766adfa7 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 9 Jun 2026 15:20:18 +1000 Subject: [PATCH 19/73] PM-5259: Fix rating popup position tiers What was broken The rating comparison popup highlighted the pyramid using rating ranges, so the position graphic could point at the wrong tier for a member's Top %. Root cause The modal reused the selected rating tier for both the rating legend and the position pyramid, even though the pyramid should be based on Top % thresholds. What was changed Added percentile-bucket selection for the pyramid: top 10% or less, 25% or less, 50% or less, 75% or less, and greater than 75%. Kept the highlighted segment color aligned with the member's rating color and made the mobile pyramid/position row explicit. Any added/updated tests Updated MemberRatingInfoModal tests to assert the position summary and percentile bucket highlighting. --- .../MemberRatingInfoModal.module.scss | 3 + .../MemberRatingInfoModal.spec.tsx | 78 ++++++++++++++----- .../MemberRatingInfoModal.tsx | 42 +++++++++- 3 files changed, 101 insertions(+), 22 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss index ef6da8731..9461a4590 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss @@ -102,8 +102,11 @@ @include ltesm { border-left: 0; border-top: 1px solid $black-10; + flex-direction: row; + flex-wrap: nowrap; gap: $sp-4; grid-column: 1 / -1; + justify-content: center; } } diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx index eb109e2a2..58e028da4 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx @@ -1,12 +1,39 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ import '@testing-library/jest-dom' import type { PropsWithChildren } from 'react' +import type { RenderResult } from '@testing-library/react' import { render, screen, within } from '@testing-library/react' import type { UserProfile } from '~/libs/core' import MemberRatingInfoModal from './MemberRatingInfoModal' +const baseProfile = { + firstName: 'Emily', + handle: 'emily', + photoURL: 'https://example.com/photo.png', +} as UserProfile + +const ratingDistribution = { + createdAt: '2026-06-04T00:00:00.000Z', + createdBy: 'test', + distribution: { + ratingRange0To899: 10, + ratingRange900To1199: 20, + ratingRange1200To1499: 30, + ratingRange1500To2199: 40, + }, + subTrack: 'AI', + track: 'DATA_SCIENCE', + updatedAt: '2026-06-04T00:00:00.000Z', + updatedBy: 'test', +} + +function getPyramidFills(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('polygon')) + .map((polygon: Element) => polygon.getAttribute('fill') ?? '') +} + jest.mock('~/libs/core', () => ({ getRatingColor: jest.fn(() => '#616BD5'), }), { @@ -41,26 +68,9 @@ describe('MemberRatingInfoModal', () => { audienceLabel='developers' onClose={jest.fn()} percentile={15} - profile={{ - firstName: 'Emily', - handle: 'emily', - photoURL: 'https://example.com/photo.png', - } as UserProfile} + profile={baseProfile} rating={1646} - ratingDistribution={{ - createdAt: '2026-06-04T00:00:00.000Z', - createdBy: 'test', - distribution: { - ratingRange0To899: 10, - ratingRange900To1199: 20, - ratingRange1200To1499: 30, - ratingRange1500To2199: 40, - }, - subTrack: 'AI', - track: 'DATA_SCIENCE', - updatedAt: '2026-06-04T00:00:00.000Z', - updatedBy: 'test', - }} + ratingDistribution={ratingDistribution} />, ) @@ -77,4 +87,34 @@ describe('MemberRatingInfoModal', () => { expect(screen.getByText('Where Emily ranks in the distribution')) .toBeInTheDocument() }) + + it('highlights pyramid segments from top percentile buckets', () => { + const rendered: RenderResult = render( + , + ) + + expect(getPyramidFills(rendered.container)) + .toEqual(['#616BD5', '#D4D4D4', '#D4D4D4', '#D4D4D4', '#D4D4D4']) + + rendered.rerender( + , + ) + + expect(getPyramidFills(rendered.container)) + .toEqual(['#D4D4D4', '#D4D4D4', '#D4D4D4', '#616BD5', '#D4D4D4']) + }) }) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx index 53b830a3f..2c5696432 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx @@ -162,6 +162,39 @@ const getRatingTierName = (rating?: number): string => ( rating === undefined ? 'Unrated' : getRatingTier(rating).tierLabel ) +/** + * Returns the pyramid tier that represents a member's top percentile. + * + * Used by MemberRatingInfoModal to place the highlighted pyramid segment by position instead of + * rating range: top 10%, top 25%, top 50%, top 75%, then everyone else. + * + * @param {number | undefined} percentile - The member's top percentile in the selected audience. + * @returns {RatingTier | undefined} The pyramid tier for the percentile, or undefined without a usable percentile. + */ +const getPyramidTierByPercentile = (percentile?: number): RatingTier | undefined => { + if (percentile === undefined || !Number.isFinite(percentile)) { + return undefined + } + + if (percentile <= 10) { + return ratingTiers[4] + } + + if (percentile <= 25) { + return ratingTiers[3] + } + + if (percentile <= 50) { + return ratingTiers[2] + } + + if (percentile <= 75) { + return ratingTiers[1] + } + + return ratingTiers[0] +} + /** * Parses the API distribution payload into ordered rating ranges. * @@ -279,7 +312,8 @@ const MemberRatingInfoModal: FC = (props: MemberRati const displayName: string = getMemberDisplayName(props.profile) const titleDisplayName: string = displayName .toUpperCase() - const selectedTier: RatingTier = getRatingTier(props.rating) + const selectedRatingTier: RatingTier = getRatingTier(props.rating) + const selectedPyramidTier: RatingTier = getPyramidTierByPercentile(props.percentile) ?? selectedRatingTier const distributionRanges: RatingDistributionRange[] = useMemo(() => ( getDistributionRanges(props.ratingDistribution?.distribution) ), [props.ratingDistribution]) @@ -346,7 +380,9 @@ const MemberRatingInfoModal: FC = (props: MemberRati ) })} @@ -438,7 +474,7 @@ const MemberRatingInfoModal: FC = (props: MemberRati
    {ratingTiers.map((tier: RatingTier) => { - const isActive = tier.id === selectedTier.id + const isActive = tier.id === selectedRatingTier.id return (
    Date: Tue, 9 Jun 2026 15:35:31 +1000 Subject: [PATCH 20/73] PM-5263: Improve low-rating card contrast What was broken The compact profile rating card rendered the lowest rating tier in the shared dark grey, making the rating and Top percentage badge hard to read on the dark AI Ratings card. Root cause (if identifiable) MemberRatingCard reused the global rating color directly. The lowest-tier grey works on light backgrounds but has poor contrast against the card's navy background. What was changed Added a compact-card rating color helper that maps the lowest rating tier to the lighter grey palette value while preserving the existing shared colors for higher tiers, and used it for the card rating value and Top percentage badge. Any added/updated tests Updated MemberRatingCard.utils.spec.ts to cover the low-tier compact color mapping and to verify higher rating tiers keep their shared colors. Focused profile tests, lint, and build were run; the full test command was also run and failed in unrelated work/wallet-admin suites outside the profile rating change. --- .../MemberRatingCard/MemberRatingCard.tsx | 6 +++-- .../MemberRatingCard.utils.spec.ts | 19 +++++++++++++++ .../MemberRatingCard.utils.ts | 23 ++++++++++++++++++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx index 6607d3c08..f43b3c9db 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx @@ -17,6 +17,7 @@ import { getPreferredRolesText, numberToFixed } from '../../../lib' import { calculateTopPercentileFromDistribution, + getCompactRatingColor, getLatestProfileRating, getPreferredRolesDisplay, getRatingAudienceLabel, @@ -47,6 +48,7 @@ const formatPercentile = (percentile: number): string => ( const MemberRatingCard: FC = (props: MemberRatingCardProps) => { const memberStats: UserStats | undefined = useMemberStats(props.profile.handle) const rating: number | undefined = useMemo(() => getLatestProfileRating(memberStats), [memberStats]) + const compactRatingColor: string = getCompactRatingColor(rating, getRatingColor(rating)) const [isInfoModalOpen, setIsInfoModalOpen]: [boolean, Dispatch>] = useState(false) @@ -151,7 +153,7 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp
    - {!isAwardsExpanded && additionalBadgeCount > 0 && ( + {additionalBadgeCount > 0 && ( )} From 6f321969ed8f5d7aedc58194fdfad3ca889b5be6 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 10 Jun 2026 12:25:23 +1000 Subject: [PATCH 26/73] PM-5300: Disable public review for design reviewers What was broken Design challenge manual reviewer specs could inherit default reviewer metadata that opened a public review opportunity, leaving the checkbox checked and editable. Root cause The human review tab applied the default reviewer's shouldOpenOpportunity flag without a Design-track override. What was changed Design manual reviewer rows now force shouldOpenOpportunity to false, render the public review opportunity checkbox disabled and unchecked, and keep member assignment controls visible. The reviewer-field documentation was updated to describe the Design-specific behavior. Any added/updated tests Added a HumanReviewTab regression test covering Design default reviewer metadata that would otherwise open a public opportunity. Focused HumanReviewTab tests, lint, and build pass. The full non-watch project test command was run and still fails in unrelated suites because of existing alias, mocked service, helper, and assertion failures outside PM-5300. --- .../challenges/ChallengeEditorPage/README.md | 2 +- .../ReviewersField/HumanReviewTab.spec.tsx | 68 +++++++++++++++++++ .../ReviewersField/HumanReviewTab.tsx | 50 +++++++++++++- 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 53b58a1c7..1d5093437 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -63,7 +63,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `ChallengeScheduleSection`: schedule editor for challenge start and phase dates. It keeps the detected timezone above the controls, renders the `Start Date` label with the `Scheduled` and `Immediately` start-mode radios aligned to the end of that header row above the input with a green selected state, persists the selected start mode in challenge metadata so saved `/edit` and `/view` routes reopen with the correct radio state, recalculates root phase dates when the challenge start changes, honors a completed predecessor phase's actual end date when deriving successor schedule rows, and keeps completed phases' end-date and duration controls locked to match legacy work-manager behavior. `Task` challenges hide this editable section across create, edit, and read-only view routes to match legacy work-manager behavior. - `DesignWorkTypeField`: shown for Design + Challenge, with the legacy work-type options (`Application Front-End Design`, `Print/Presentation`, `Web Design`, `Widget or Mobile Screen Design`, `Wireframes`). The selected value is stored in challenge tags. - `FunChallengeField`: shown for `Marathon Match` type and remains editable after creation so the form can switch between fun-challenge and standard marathon-match fields. -- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. On the human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. +- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. On the human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. Design challenge manual reviewers always keep the public review opportunity checkbox disabled and unchecked. - `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and submission-visibility controls. - `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages. - `MaximumSubmissionsField`: non-visual compatibility field that rewrites the legacy `submissionLimit` metadata to the unlimited-only payload so design challenges no longer expose submission-cap controls. It defers dirtying that automatic normalization until the editor finishes its initial resource hydration, including the first render after asynchronously loaded challenge details arrive, which preserves copilot restoration before autosave/manual-save starts treating the metadata rewrite as a user change. diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx index 352cc6a87..8b4436fee 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx @@ -252,6 +252,7 @@ interface TestHarnessProps { restoreStaleScorecardId?: boolean showAdditionalMemberIdsValue?: boolean showMemberValue?: boolean + showPublicOpportunityValue?: boolean showRoleValue?: boolean showRoleValueIndex?: number showScorecardValue?: boolean @@ -432,6 +433,13 @@ const TestHarness = (props: TestHarnessProps): JSX.Element => {
    ) : undefined} + {props.showPublicOpportunityValue + ? ( +
    + {String(formMethods.watch('reviewers.0.shouldOpenOpportunity'))} +
    + ) + : undefined} {props.showRoleValue ? (
    @@ -799,6 +807,66 @@ describe('HumanReviewTab', () => { }) }) + it('disables and clears public review opportunity for design manual reviewers', async () => { + mockedUseFetchChallengeTracks.mockReturnValue({ + tracks: [ + { + id: 'track-1', + name: 'Design', + track: 'DESIGN', + }, + ], + }) + mockedFetchDefaultReviewers.mockResolvedValue([ + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'phase-1', + roleId: 'role-1', + scorecardId: 'scorecard-1', + shouldOpenOpportunity: true, + }, + ]) + mockedFetchScorecards.mockResolvedValue([ + { + id: 'scorecard-1', + name: 'Scorecard 1', + phaseId: 'phase-1', + }, + ]) + + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('button', { name: 'Add reviewer' }) as HTMLButtonElement).disabled) + .toBe(false) + }) + + fireEvent.click(screen.getByRole('button', { name: 'Add reviewer' })) + + const checkbox = await screen.findByRole('checkbox', { + name: 'Open public review opportunity', + }) as HTMLInputElement + + expect(checkbox.checked) + .toBe(false) + expect(checkbox.disabled) + .toBe(true) + await waitFor(() => { + expect(screen.getByTestId('public-opportunity-value').textContent) + .toBe('false') + }) + expect(screen.getByTestId('reviewers.0.memberId')) + .not.toBeNull() + }) + it('defaults new manual reviewer cards to regular review type', async () => { mockedFetchScorecards.mockResolvedValue([]) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx index c4bb96680..16222c2a0 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx @@ -762,6 +762,7 @@ function getErrorMessage(error: unknown, fallbackMessage: string): string { } interface PublicOpportunityCheckboxFieldProps { + disabled?: boolean name: string onChange?: (checked: boolean) => void } @@ -775,10 +776,14 @@ const PublicOpportunityCheckboxField: FC = name: props.name, }) const field = controller.field - const checked = field.value === true + const checked = !props.disabled && field.value === true const handleChange = useCallback( (event: ChangeEvent): void => { + if (props.disabled) { + return + } + const nextValue = event.target.checked field.onChange(nextValue) props.onChange?.(nextValue) @@ -798,6 +803,7 @@ const PublicOpportunityCheckboxField: FC = { }, [challengeTracks, normalizedTrackId], ) + const isDesignTrackSelected = selectedScorecardTrack === 'DESIGN' const selectedScorecardType = useMemo( (): string => { if (!normalizedTypeId) { @@ -1228,6 +1235,37 @@ export const HumanReviewTab: FC = () => { reviewerRows, ]) + useEffect(() => { + if (!isDesignTrackSelected) { + return + } + + reviewerRows.forEach((reviewer, reviewerIndex) => { + const fieldIndex = getReviewerFieldIndex(reviewerIndex) + + if ( + fieldIndex === undefined + || !isPublicOpportunityOpen(reviewer) + ) { + return + } + + formContext.setValue( + `reviewers.${fieldIndex}.shouldOpenOpportunity` as any, + false, + { + shouldDirty: false, + shouldValidate: true, + }, + ) + }) + }, [ + formContext, + getReviewerFieldIndex, + isDesignTrackSelected, + reviewerRows, + ]) + useEffect(() => { if (isScorecardsLoading || loadError) { return @@ -1797,6 +1835,9 @@ export const HumanReviewTab: FC = () => { ...reviewerFromDefaults, phaseId, roleId: roleIdForResolvedPhase || reviewerFromDefaults.roleId, + shouldOpenOpportunity: isDesignTrackSelected + ? false + : reviewerFromDefaults.shouldOpenOpportunity, }, ], { shouldDirty: true, @@ -1807,6 +1848,7 @@ export const HumanReviewTab: FC = () => { defaultReviewers, allReviewerRows, formContext, + isDesignTrackSelected, phaseNameById, phases, reviewerRows, @@ -1930,7 +1972,9 @@ export const HumanReviewTab: FC = () => { return undefined } - const shouldOpenOpportunity = isPublicOpportunityOpen(reviewer) + const shouldOpenOpportunity = isDesignTrackSelected + ? false + : isPublicOpportunityOpen(reviewer) const reviewerCount = getReviewerCount(reviewer) const scorecardOptions = getScorecardOptionsForReviewer(reviewer) const reviewerPrefix = `reviewers.${fieldIndex}` @@ -1938,6 +1982,7 @@ export const HumanReviewTab: FC = () => { || reviewer.phaseId || index const reviewerKey = `${reviewerPrefix}-${reviewerIdentity}` + const shouldDisablePublicOpportunity = isDesignTrackSelected return (
    { options={REVIEW_OPPORTUNITY_OPTIONS} /> From 39381dbd8134f33ca9033214a8dbf4cd0868d912 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 15 Jun 2026 11:12:11 +1000 Subject: [PATCH 27/73] PM-5349: add reports talent tab What was broken Reports had no administrator-only Talent view for open-to-work members grouped by preferred role. Root cause The reports UI only exposed generic report download and search flows, with no route, service client, or tab wired to the new preferred-role data. What was changed Added an admin-gated Talent tab with role counts, donut visualization, role and availability filters, a paginated member table, and CSV download wiring. Any added/updated tests Added Talent page utility coverage. Verified with yarn test:no-watch TalentPage.utils, yarn lint, and yarn run build. The full yarn test:no-watch suite was run and still has unrelated pre-existing failures outside reports. --- src/apps/reports/src/config/routes.config.ts | 1 + .../src/lib/components/NavTabs/NavTabs.tsx | 50 +- src/apps/reports/src/lib/services/index.ts | 8 + .../src/lib/services/reports.service.ts | 94 ++++ .../src/pages/talent/TalentPage.module.scss | 398 ++++++++++++++++ .../reports/src/pages/talent/TalentPage.tsx | 443 ++++++++++++++++++ .../src/pages/talent/TalentPage.utils.spec.ts | 28 ++ .../src/pages/talent/TalentPage.utils.ts | 76 +++ src/apps/reports/src/pages/talent/index.ts | 1 + src/apps/reports/src/reports-app.routes.tsx | 10 + 10 files changed, 1094 insertions(+), 15 deletions(-) create mode 100644 src/apps/reports/src/pages/talent/TalentPage.module.scss create mode 100644 src/apps/reports/src/pages/talent/TalentPage.tsx create mode 100644 src/apps/reports/src/pages/talent/TalentPage.utils.spec.ts create mode 100644 src/apps/reports/src/pages/talent/TalentPage.utils.ts create mode 100644 src/apps/reports/src/pages/talent/index.ts diff --git a/src/apps/reports/src/config/routes.config.ts b/src/apps/reports/src/config/routes.config.ts index 6545d0afc..cc76b5dd3 100644 --- a/src/apps/reports/src/config/routes.config.ts +++ b/src/apps/reports/src/config/routes.config.ts @@ -11,3 +11,4 @@ export const rootRoute: string export const reportsPageRouteId = 'reports' export const bulkMemberLookupRouteId = 'bulk-member-lookup' export const billingAccountsPageRouteId = 'sfdc-payments' +export const talentPageRouteId = 'talent' diff --git a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx index 3ee088f20..602777c7c 100644 --- a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx +++ b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx @@ -4,6 +4,7 @@ import { MouseEvent, SetStateAction, useCallback, + useContext, useEffect, useMemo, useRef, @@ -14,12 +15,15 @@ import classNames from 'classnames' import { useClickOutside } from '~/libs/shared/lib/hooks' import { TabsNavItem } from '~/libs/ui' +import { UserRole } from '~/libs/core' import { billingAccountsPageRouteId, bulkMemberLookupRouteId, reportsPageRouteId, + talentPageRouteId, } from '../../../config/routes.config' +import { ReportsAppContext, ReportsAppContextModel } from '../../contexts' import styles from './NavTabs.module.scss' @@ -28,21 +32,37 @@ const NavTabs: FC = () => { const [isOpen, setIsOpen] = useState(false) const triggerRef = useRef(null) const { pathname }: { pathname: string } = useLocation() - - const tabs = useMemo(() => [ - { - id: reportsPageRouteId, - title: 'Reports', - }, - { - id: bulkMemberLookupRouteId, - title: 'Bulk Member Lookup', - }, - { - id: billingAccountsPageRouteId, - title: 'SFDC Payments', - }, - ], []) + const { loginUserInfo }: ReportsAppContextModel = useContext(ReportsAppContext) + const isAdministrator = useMemo(() => ( + !!loginUserInfo?.roles?.some(role => role.toLowerCase() === UserRole.administrator) + ), [loginUserInfo]) + + const tabs = useMemo(() => { + const baseTabs: TabsNavItem[] = [ + { + id: reportsPageRouteId, + title: 'Reports', + }, + { + id: bulkMemberLookupRouteId, + title: 'Bulk Member Lookup', + }, + { + id: billingAccountsPageRouteId, + title: 'SFDC Payments', + }, + ] + + return isAdministrator + ? [ + ...baseTabs, + { + id: talentPageRouteId, + title: 'Talent', + }, + ] + : baseTabs + }, [isAdministrator]) const activeTabPathName: string = useMemo(() => { const matchingTabs = tabs diff --git a/src/apps/reports/src/lib/services/index.ts b/src/apps/reports/src/lib/services/index.ts index 567bbcf6a..bb718795c 100644 --- a/src/apps/reports/src/lib/services/index.ts +++ b/src/apps/reports/src/lib/services/index.ts @@ -1,7 +1,10 @@ export { + buildOpenToWorkTalentPath, downloadBlobFile, + downloadOpenToWorkTalentCsv, downloadReportAsCsv, downloadReportAsJson, + fetchOpenToWorkTalent, fetchReportJson, fetchReportsIndex, postReportAsCsv, @@ -14,6 +17,11 @@ export type { BillingAccountDetail, BillingAccountProfileResponse, BillingAccountsViewData, + OpenToWorkTalentAvailability, + OpenToWorkTalentMember, + OpenToWorkTalentQuery, + OpenToWorkTalentResponse, + OpenToWorkTalentRoleCount, ReportDefinition, ReportGroup, ReportParameter, diff --git a/src/apps/reports/src/lib/services/reports.service.ts b/src/apps/reports/src/lib/services/reports.service.ts index a6c4ed649..220c0e774 100644 --- a/src/apps/reports/src/lib/services/reports.service.ts +++ b/src/apps/reports/src/lib/services/reports.service.ts @@ -68,6 +68,44 @@ export type BillingAccountsViewData = { payments: SfdcBillingAccountPaymentRow[] } +export type OpenToWorkTalentAvailability = 'FULL_TIME' | 'PART_TIME' + +export type OpenToWorkTalentQuery = { + role?: string + availability?: OpenToWorkTalentAvailability + page?: number + perPage?: number +} + +export type OpenToWorkTalentRoleCount = { + role: string + count: number +} + +export type OpenToWorkTalentMember = { + userId: string + handle: string + firstName: string | null + lastName: string | null + country: string | null + availability: string | null + preferredRoles: string[] + memberSince: string | null + maxRating: number | null + challengeWins: number + taskWins: number + totalWins: number +} + +export type OpenToWorkTalentResponse = { + totalMembers: number + total: number + page: number + perPage: number + roleCounts: OpenToWorkTalentRoleCount[] + data: OpenToWorkTalentMember[] +} + const reportsDownloadClient: AxiosInstance = xhrCreateInstance() const buildReportUrl = (path: string): string => { @@ -75,6 +113,38 @@ const buildReportUrl = (path: string): string => { return `${EnvironmentConfig.API.V6}/reports${normalizedPath}` } +/** + * Builds a reports API path with Talent query parameters. + * @param basePath Reports API path relative to `/reports`. + * @param query Optional role, availability, and pagination values. + * @returns Path and query string suitable for reports API calls. + */ +export const buildOpenToWorkTalentPath = ( + basePath: string, + query: OpenToWorkTalentQuery = {}, +): string => { + const params = new URLSearchParams() + + if (query.role) { + params.append('role', query.role) + } + + if (query.availability) { + params.append('availability', query.availability) + } + + if (query.page) { + params.append('page', String(query.page)) + } + + if (query.perPage) { + params.append('perPage', String(query.perPage)) + } + + const queryString = params.toString() + return queryString ? `${basePath}?${queryString}` : basePath +} + export const fetchReportsIndex = async (): Promise => ( xhrGetAsync(`${EnvironmentConfig.API.V6}/reports/directory`) ) @@ -191,6 +261,30 @@ export const downloadReportAsCsv = (path: string): Promise => ( downloadReportBlob(path, 'text/csv') ) +/** + * Fetches the Talent tab dashboard and paginated member list. + * @param query Optional role, availability, and pagination values. + * @returns Open-to-work Talent report data. + */ +export const fetchOpenToWorkTalent = ( + query: OpenToWorkTalentQuery, +): Promise => ( + fetchReportJson( + buildOpenToWorkTalentPath('/member/open-to-work', query), + ) +) + +/** + * Downloads the Talent tab CSV export for the selected filters. + * @param query Optional role and availability filters. + * @returns Blob response encoded as CSV. + */ +export const downloadOpenToWorkTalentCsv = ( + query: OpenToWorkTalentQuery, +): Promise => ( + downloadReportAsCsv(buildOpenToWorkTalentPath('/member/open-to-work/export', query)) +) + /** * Triggers a browser download for a report blob. * @param blob the report data returned from the reports API. diff --git a/src/apps/reports/src/pages/talent/TalentPage.module.scss b/src/apps/reports/src/pages/talent/TalentPage.module.scss new file mode 100644 index 000000000..8fb04cb20 --- /dev/null +++ b/src/apps/reports/src/pages/talent/TalentPage.module.scss @@ -0,0 +1,398 @@ +@import '@libs/ui/styles/includes'; + +.page { + display: flex; + flex-direction: column; + gap: 24px; +} +.header { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: flex-start; + + h1 { + margin: 0 0 6px; + font-size: 32px; + line-height: 40px; + color: #111b46; + } + + p { + margin: 0; + color: #5a6488; + } +} + +.headerActions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 12px; +} + +.summaryGrid { + display: grid; + grid-template-columns: minmax(240px, 360px) minmax(0, 1fr); + gap: 16px; +} + +.metricPanel, +.rolesPanel, +.membersSection { + border: 1px solid #e3e7ef; + border-radius: 8px; + background: #fff; + box-shadow: 0 2px 8px rgba(18, 24, 40, 0.05); +} + +.metricPanel { + min-height: 220px; + display: flex; + flex-direction: column; + justify-content: center; + padding: 28px; +} + +.panelLabel { + font-weight: 700; + text-transform: uppercase; + color: #111b46; + font-size: 13px; + letter-spacing: 0.02em; +} + +.metricPanel strong { + margin-top: 18px; + color: #0f62fe; + font-size: 52px; + line-height: 60px; +} + +.metricMeta { + margin-top: 4px; + color: #5a6488; +} + +.rolesPanel { + min-height: 220px; + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + align-items: center; + gap: 24px; + padding: 24px; +} + +.rolesChartWrap { + display: flex; + justify-content: center; +} + +.donut { + width: 180px; + height: 180px; + border-radius: 50%; + display: grid; + place-items: center; +} + +.donutInner { + width: 104px; + height: 104px; + border-radius: 50%; + background: #fff; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #111b46; + + strong { + font-size: 24px; + line-height: 30px; + } + + span { + color: #5a6488; + font-size: 13px; + } +} + +.roleList { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: 8px 20px; + + button { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + min-height: 38px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: #111b46; + cursor: pointer; + font: inherit; + text-align: left; + } + + button:hover, + .activeRole { + border-color: #c7d7ff; + background: #f4f7ff; + } +} + +.roleName { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.swatch { + width: 10px; + height: 10px; + border-radius: 50%; + flex: 0 0 auto; +} + +.membersSection { + padding: 20px 24px 12px; +} + +.membersHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 16px; + + h2 { + display: inline-flex; + align-items: center; + gap: 10px; + margin: 0; + color: #111b46; + font-size: 22px; + line-height: 30px; + } +} + +.countBadge { + display: inline-flex; + align-items: center; + min-height: 28px; + margin-left: 10px; + padding: 4px 8px; + border-radius: 6px; + background: #e9f1ff; + color: #0f62fe; + font-weight: 700; +} + +.filterGroup { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 12px; + color: #111b46; + font-weight: 600; +} + +.segmentedControl { + display: inline-flex; + border: 1px solid #d6dbe8; + border-radius: 6px; + overflow: hidden; + + button { + min-height: 34px; + padding: 6px 12px; + border: 0; + border-right: 1px solid #d6dbe8; + background: #fff; + color: #34406b; + cursor: pointer; + font: inherit; + } + + button:last-child { + border-right: 0; + } + + button:hover, + .activeSegment { + background: #0f62fe; + color: #fff; + } +} + +.tableWrap { + overflow-x: auto; + border: 1px solid #e3e7ef; + border-radius: 8px; +} + +.membersTable { + width: 100%; + min-width: 980px; + border-collapse: collapse; + font-size: 13px; + + th, + td { + padding: 12px 14px; + text-align: left; + border-bottom: 1px solid #edf0f6; + vertical-align: top; + } + + th { + background: #f7f8fb; + color: #5a6488; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + white-space: nowrap; + } + + tbody tr:last-child td { + border-bottom: 0; + } +} + +.memberCell { + display: flex; + flex-direction: column; + gap: 2px; + + strong { + color: #0f62fe; + font-size: 14px; + } + + span { + color: #5a6488; + } +} + +.roleChips { + display: flex; + flex-wrap: wrap; + gap: 6px; + + span { + display: inline-flex; + align-items: center; + max-width: 240px; + min-height: 24px; + padding: 3px 8px; + border-radius: 6px; + background: #f1f5f9; + color: #26345d; + white-space: normal; + } +} + +.profileLink { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + padding: 6px 12px; + border: 1px solid #0f62fe; + border-radius: 6px; + color: #0f62fe; + font-weight: 700; + white-space: nowrap; +} + +.profileLink:hover { + background: #f4f7ff; + color: #0043ce; +} + +.emptyState { + padding: 24px; + color: #5a6488; + font-style: italic; +} + +.paginationBar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 12px; + color: #5a6488; + font-size: 13px; +} + +.rowsControl { + display: flex; + align-items: center; + gap: 8px; + + label { + color: #5a6488; + } + + select { + min-width: 72px; + padding: 5px 8px; + border: 1px solid #d6dbe8; + border-radius: 6px; + background: #fff; + color: #111b46; + } +} + +@media (max-width: 1180px) { + .summaryGrid, + .rolesPanel { + grid-template-columns: 1fr; + } + + .roleList { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .header, + .membersHeader, + .filterGroup { + align-items: stretch; + flex-direction: column; + } + + .headerActions, + .filterGroup { + justify-content: flex-start; + } + + .metricPanel, + .rolesPanel, + .membersSection { + padding: 16px; + } + + .metricPanel strong { + font-size: 42px; + line-height: 50px; + } + + .segmentedControl { + width: 100%; + + button { + flex: 1 1 0; + } + } +} diff --git a/src/apps/reports/src/pages/talent/TalentPage.tsx b/src/apps/reports/src/pages/talent/TalentPage.tsx new file mode 100644 index 000000000..8878fa848 --- /dev/null +++ b/src/apps/reports/src/pages/talent/TalentPage.tsx @@ -0,0 +1,443 @@ +import { + ChangeEvent, + CSSProperties, + FC, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import { Navigate } from 'react-router-dom' + +import { EnvironmentConfig } from '~/config' +import { ReportsAppContext, ReportsAppContextModel } from '~/apps/reports/src/lib' +import { Pagination } from '~/apps/admin/src/lib' +import { UserRole } from '~/libs/core' +import { + Button, + IconOutline, + LoadingSpinner, + PageTitle, +} from '~/libs/ui' + +import { + downloadBlobFile, + downloadOpenToWorkTalentCsv, + fetchOpenToWorkTalent, + OpenToWorkTalentAvailability, + OpenToWorkTalentMember, + OpenToWorkTalentResponse, + OpenToWorkTalentRoleCount, +} from '../../lib/services' +import { handleError } from '../../lib/utils' +import { reportsPageRouteId, rootRoute } from '../../config/routes.config' + +import { + formatAvailability, + formatMemberSince, + formatPreferredRole, +} from './TalentPage.utils' +import styles from './TalentPage.module.scss' + +const pageTitle = 'Talent' +const rowsPerPageOptions = [10, 25, 50] +const chartColors = [ + '#0f62fe', + '#2ebac6', + '#6aae3f', + '#ff8a00', + '#6c5ce7', + '#e82f72', + '#f6b31a', + '#2d7f75', + '#8f6448', + '#aeb5c8', + '#4b7bec', + '#d653a3', +] + +const reportsLandingRoute = rootRoute || `/${reportsPageRouteId}` + +type TalentRoleSegment = OpenToWorkTalentRoleCount & { + color: string + label: string + percent: number +} + +type AvailabilityOption = { + label: string + value?: OpenToWorkTalentAvailability +} + +const availabilityOptions: AvailabilityOption[] = [ + { label: 'All', value: undefined }, + { label: 'Full-time', value: 'FULL_TIME' }, + { label: 'Part-time', value: 'PART_TIME' }, +] + +/** + * Returns true when the loaded token belongs to an administrator. + * @param roles Roles from the decoded Topcoder token. + * @returns Whether the role list includes the administrator role. + */ +function hasAdministratorRole(roles?: string[]): boolean { + return !!roles?.some(role => role.toLowerCase() === UserRole.administrator) +} + +/** + * Builds the conic-gradient background used by the role summary chart. + * @param segments Role count segments with colors and percentages. + * @returns CSS background value for the donut chart. + */ +function buildDonutBackground(segments: TalentRoleSegment[]): string { + if (!segments.length) { + return '#e8ebf2' + } + + let cursor = 0 + const stops = segments.map(segment => { + const start = cursor + const end = cursor + segment.percent + cursor = end + return `${segment.color} ${start}% ${end}%` + }) + + return `conic-gradient(${stops.join(', ')})` +} + +/** + * Formats member first/last name values for table display. + * @param member Open-to-work member row. + * @returns Display name, falling back to handle. + */ +function formatMemberName(member: OpenToWorkTalentMember): string { + const name = [member.firstName, member.lastName] + .map(value => value?.trim()) + .filter(Boolean) + .join(' ') + + return name || member.handle +} + +/** + * Admin-only Talent report page for open-to-work members. + * + * It fetches preferred-role aggregates, renders a role-filterable member list, + * and downloads the matching CSV export from reports-api. + */ +// eslint-disable-next-line complexity +const TalentPage: FC = () => { + const { loginUserInfo }: ReportsAppContextModel = useContext(ReportsAppContext) + const isAuthLoaded = loginUserInfo !== undefined + const isAdministrator = hasAdministratorRole(loginUserInfo?.roles) + + const [selectedRole, setSelectedRole] = useState(undefined) + const [availability, setAvailability] = useState(undefined) + const [page, setPage] = useState(1) + const [perPage, setPerPage] = useState(rowsPerPageOptions[0]) + const [data, setData] = useState(undefined) + const [isLoading, setIsLoading] = useState(false) + const [isDownloading, setIsDownloading] = useState(false) + const [refreshKey, setRefreshKey] = useState(0) + + const roleSegments = useMemo(() => { + const roleTotal = (data?.roleCounts ?? []) + .reduce((total, roleCount) => total + roleCount.count, 0) + + return (data?.roleCounts ?? []).map((roleCount, index) => ({ + ...roleCount, + color: chartColors[index % chartColors.length], + label: formatPreferredRole(roleCount.role), + percent: roleTotal > 0 ? (roleCount.count / roleTotal) * 100 : 0, + })) + }, [data]) + + const donutStyle = useMemo(() => ({ + background: buildDonutBackground(roleSegments), + }), [roleSegments]) + + const selectedRoleLabel = selectedRole ? formatPreferredRole(selectedRole) : 'All roles' + const totalPages = Math.max(1, Math.ceil((data?.total ?? 0) / perPage)) + const totalShownStart = data?.total ? ((page - 1) * perPage) + 1 : 0 + const totalShownEnd = Math.min(page * perPage, data?.total ?? 0) + const paginationLabel = `Showing ${totalShownStart}-${totalShownEnd} of ${ + (data?.total ?? 0).toLocaleString() + } members` + + useEffect(() => { + if (!isAdministrator) { + return undefined + } + + let cancelled = false + setIsLoading(true) + + fetchOpenToWorkTalent({ + availability, + page, + perPage, + role: selectedRole, + }) + .then(response => { + if (!cancelled) { + setData(response) + } + }) + .catch(handleError) + .finally(() => { + if (!cancelled) { + setIsLoading(false) + } + }) + + return () => { + cancelled = true + } + }, [availability, isAdministrator, page, perPage, refreshKey, selectedRole]) + + useEffect(() => { + if (page > totalPages) { + setPage(totalPages) + } + }, [page, totalPages]) + + const handleSelectAllRoles = useCallback(() => { + setSelectedRole(undefined) + setPage(1) + }, []) + + const createHandleRoleClick = useCallback((role: string) => () => { + setSelectedRole(role) + setPage(1) + }, []) + + const createHandleAvailabilityClick = useCallback(( + nextAvailability?: OpenToWorkTalentAvailability, + ) => () => { + setAvailability(nextAvailability) + setPage(1) + }, []) + + const handleRowsPerPageChange = useCallback((event: ChangeEvent) => { + setPerPage(Number(event.target.value)) + setPage(1) + }, []) + + const handleRefresh = useCallback(() => { + setRefreshKey(key => key + 1) + }, []) + + const handleDownload = useCallback(async (): Promise => { + try { + setIsDownloading(true) + const blob = await downloadOpenToWorkTalentCsv({ + availability, + role: selectedRole, + }) + const rolePart = selectedRole ? selectedRole.toLowerCase() : 'all-roles' + downloadBlobFile(blob, `open-to-work-talent-${rolePart}.csv`) + } catch (error) { + handleError(error) + } finally { + setIsDownloading(false) + } + }, [availability, selectedRole]) + + if (isAuthLoaded && !isAdministrator) { + return + } + + if (!isAuthLoaded) { + return + } + + return ( + <> + {pageTitle} + {(isLoading || isDownloading) && ( + + )} + +
    +
    +
    +

    Talent

    +

    Open-to-work Topcoder members grouped by preferred role.

    +
    +
    + + +
    +
    + +
    +
    + Open to work + {(data?.totalMembers ?? 0).toLocaleString()} + members with preferred roles +
    + +
    +
    +
    +
    + {(data?.totalMembers ?? 0).toLocaleString()} + members +
    +
    +
    +
    + + {roleSegments.map(segment => ( + + ))} +
    +
    +
    + +
    +
    +
    +

    Members open to work

    + {(data?.total ?? 0).toLocaleString()} +
    +
    + {selectedRoleLabel} +
    + {availabilityOptions.map(option => ( + + ))} +
    +
    +
    + +
    + + + + + + + + + + + + + + + {(data?.data ?? []).map(member => ( + + + + + + + + + + + ))} + +
    MemberPreferred rolesAvailabilityCountryMember sinceRatingWinsActions
    +
    + {formatMemberName(member)} + {member.handle} +
    +
    +
    + {member.preferredRoles.map(role => ( + {formatPreferredRole(role)} + ))} +
    +
    {formatAvailability(member.availability)}{member.country || 'Not available'}{formatMemberSince(member.memberSince)}{member.maxRating ?? 'Not rated'}{member.totalWins.toLocaleString()} + + View profile + +
    + {!isLoading && data?.data.length === 0 && ( +
    No members match the selected filters.
    + )} +
    + +
    + {paginationLabel} +
    + + +
    + +
    +
    +
    + + ) +} + +export default TalentPage diff --git a/src/apps/reports/src/pages/talent/TalentPage.utils.spec.ts b/src/apps/reports/src/pages/talent/TalentPage.utils.spec.ts new file mode 100644 index 000000000..c7b277cab --- /dev/null +++ b/src/apps/reports/src/pages/talent/TalentPage.utils.spec.ts @@ -0,0 +1,28 @@ +import { + formatAvailability, + formatMemberSince, + formatPreferredRole, +} from './TalentPage.utils' + +describe('TalentPage utils', () => { + it('formats known and unknown preferred roles', () => { + expect(formatPreferredRole('FULL_STACK_DEVELOPER')) + .toBe('Full-Stack Developer') + expect(formatPreferredRole('CUSTOM_ROLE_VALUE')) + .toBe('Custom Role Value') + }) + + it('formats availability values', () => { + expect(formatAvailability('FULL_TIME')) + .toBe('Full-time') + expect(formatAvailability(undefined)) + .toBe('Not specified') + }) + + it('formats member since dates', () => { + expect(formatMemberSince('2024-02-03T00:00:00.000Z')) + .toMatch(/2024/) + expect(formatMemberSince('invalid')) + .toBe('Not available') + }) +}) diff --git a/src/apps/reports/src/pages/talent/TalentPage.utils.ts b/src/apps/reports/src/pages/talent/TalentPage.utils.ts new file mode 100644 index 000000000..874a87500 --- /dev/null +++ b/src/apps/reports/src/pages/talent/TalentPage.utils.ts @@ -0,0 +1,76 @@ +export const availabilityLabels: Record = { + FULL_TIME: 'Full-time', + PART_TIME: 'Part-time', +} + +export const preferredRoleLabels: Record = { + AI_ML_ENGINEER: 'AI / ML Engineer', + AI_PROMPT_ENGINEER: 'AI Prompt Engineer', + CLOUD_ENGINEER: 'Cloud Engineer / Solutions Architect', + CYBERSECURITY_ENGINEER: 'Cybersecurity Analyst / Security Engineer', + DATA_SCIENTIST_ENGINEER: 'Data Scientist / Data Engineer', + DB_ADMIN: 'Database Administrator', + DEVOPS_SRE: 'DevOps Engineer / SRE', + ENTERPRISE_ARCHITECT: 'Enterprise Architect', + FULL_STACK_DEVELOPER: 'Full-Stack Developer', + QA_AUTOMATION_ENGINEER: 'QA Lead / Automation Engineer', + TECHNICAL_PM: 'Technical Project Manager', + UX_DESIGNER: 'UX Designer', +} + +/** + * Formats a preferred-role value for display. + * @param role Preferred role value stored in the openToWork trait. + * @returns Human-readable role label. + */ +export function formatPreferredRole(role: string): string { + if (preferredRoleLabels[role]) { + return preferredRoleLabels[role] + } + + return role + .toLowerCase() + .split(/[_\s-]+/) + .filter(Boolean) + .map(part => { + const firstLetter = part.charAt(0) + .toUpperCase() + return `${firstLetter}${part.slice(1)}` + }) + .join(' ') +} + +/** + * Formats an open-to-work availability value for display. + * @param availability Availability value from the openToWork trait. + * @returns Human-readable availability label. + */ +export function formatAvailability(availability: string | null | undefined): string { + if (!availability) { + return 'Not specified' + } + + return availabilityLabels[availability] ?? formatPreferredRole(availability) +} + +/** + * Formats an ISO date as a compact member-since label. + * @param isoDate Date string returned by the reports API. + * @returns Month/year label, or fallback text when no valid date exists. + */ +export function formatMemberSince(isoDate: string | null | undefined): string { + if (!isoDate) { + return 'Not available' + } + + const parsed = new Date(isoDate) + + if (Number.isNaN(parsed.getTime())) { + return 'Not available' + } + + return parsed.toLocaleDateString(undefined, { + month: 'short', + year: 'numeric', + }) +} diff --git a/src/apps/reports/src/pages/talent/index.ts b/src/apps/reports/src/pages/talent/index.ts new file mode 100644 index 000000000..a58ff9dfc --- /dev/null +++ b/src/apps/reports/src/pages/talent/index.ts @@ -0,0 +1 @@ +export { default as TalentPage } from './TalentPage' diff --git a/src/apps/reports/src/reports-app.routes.tsx b/src/apps/reports/src/reports-app.routes.tsx index 01c720710..b77563358 100644 --- a/src/apps/reports/src/reports-app.routes.tsx +++ b/src/apps/reports/src/reports-app.routes.tsx @@ -15,6 +15,7 @@ import { bulkMemberLookupRouteId, reportsPageRouteId, rootRoute, + talentPageRouteId, } from './config/routes.config' const ReportsApp: LazyLoadedComponent = lazyLoad(() => import('./ReportsApp')) @@ -29,6 +30,10 @@ const BulkMemberLookupPage: LazyLoadedComponent = lazyLoad( () => import('./pages/bulk-member-lookup/BulkMemberLookupPage'), 'BulkMemberLookupPage', ) +const TalentPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/talent'), + 'TalentPage', +) export const toolTitle: string = ToolTitle.reports @@ -57,6 +62,11 @@ export const reportsRoutes: ReadonlyArray = [ element: , route: bulkMemberLookupRouteId, }, + { + authRequired: true, + element: , + route: talentPageRouteId, + }, ], domain: AppSubdomain.reports, element: , From 977b60aa0be8d44a31b366b8dd56f777117f5e5b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 15 Jun 2026 11:46:55 +1000 Subject: [PATCH 28/73] PM-5324: Hide percentile for zero ratings What was broken Member profiles could render percentile ranking details for a member whose max rating value was zero, making an unrated member appear ranked. Root cause The rating card rendered percentile details whenever a percentile existed. A zero rating returned as a string could still pass the card render check. What was changed Normalized the rating check in MemberRatingCard and only render percentile details when the max rating is greater than zero. Zero ratings can still render the rating value without the percentile block. Any added/updated tests Added MemberRatingCard tests for zero-rating and positive-rating percentile behavior. --- .../MemberRatingCard.spec.tsx | 77 +++++++++++++++++++ .../MemberRatingCard/MemberRatingCard.tsx | 12 ++- 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx new file mode 100644 index 000000000..b48a26977 --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx @@ -0,0 +1,77 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' + +import type { UserProfile, UserStats } from '~/libs/core' +import { useMemberStats } from '~/libs/core' + +import MemberRatingCard from './MemberRatingCard' + +jest.mock('~/libs/core', () => ({ + useMemberStats: jest.fn(), +}), { + virtual: true, +}) + +jest.mock('./MemberRatingInfoModal', () => ({ + MemberRatingInfoModal: () =>
    , +})) + +const mockedUseMemberStats = useMemberStats as jest.MockedFunction +const profile = { handle: 'dave' } as UserProfile + +describe('MemberRatingCard', () => { + it('hides percentile details when the member rating is zero', () => { + mockedUseMemberStats.mockReturnValue({ + DATA_SCIENCE: { + MARATHON_MATCH: { + rank: { + percentile: 100, + rating: 0, + }, + }, + }, + maxRating: { + rating: '0' as unknown as number, + }, + } as UserStats) + + render() + + expect(screen.getByText('0')) + .toBeInTheDocument() + expect(screen.getByText('Rating')) + .toBeInTheDocument() + expect(screen.queryByText('100.00')) + .not + .toBeInTheDocument() + expect(screen.queryByText('Percentile')) + .not + .toBeInTheDocument() + }) + + it('shows percentile details when the member has a positive rating', () => { + mockedUseMemberStats.mockReturnValue({ + DATA_SCIENCE: { + MARATHON_MATCH: { + rank: { + percentile: 42, + rating: 1200, + }, + }, + }, + maxRating: { + rating: 1200, + }, + } as UserStats) + + render() + + expect(screen.getByText('1200')) + .toBeInTheDocument() + expect(screen.getByText('42.00')) + .toBeInTheDocument() + expect(screen.getByText('Percentile')) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx index 3ca7e39d0..1ee05829d 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx @@ -15,6 +15,9 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp const memberStats: UserStats | undefined = useMemberStats(props.profile.handle) const [isInfoModalOpen, setIsInfoModalOpen]: [boolean, Dispatch>] = useState(false) + const maxRating = memberStats?.maxRating?.rating + const hasMaxRating = maxRating !== undefined && maxRating !== null + const hasPositiveRating = Number(maxRating) > 0 const maxPercentile: number = useMemo(() => { let memberPercentile: number = 0 @@ -36,6 +39,7 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp return memberPercentile }, [memberStats]) + const showPercentile = hasPositiveRating && maxPercentile > 0 function handleInfoModalClose(): void { setIsInfoModalOpen(false) @@ -45,15 +49,15 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp setIsInfoModalOpen(true) } - return memberStats?.maxRating?.rating ? ( + return hasMaxRating ? (
    -
    -

    {memberStats?.maxRating?.rating}

    +
    +

    {maxRating}

    Rating

    { - maxPercentile ? ( + showPercentile ? (

    {Number(maxPercentile) From d1d4db87be49f88ec0e9ec7150f724a8686b8cfd Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 15 Jun 2026 12:02:55 +1000 Subject: [PATCH 29/73] PM-5221: show Data Science Challenge stats What was broken The previous API fixes persisted and returned Data Science Challenge stats under DATA_SCIENCE.Challenge, but the profile achievements UI only considered DATA_SCIENCE.MARATHON_MATCH for the Data Science track. After a Data Science Challenge completed, the main profile rating could increase while the Member Stats card and drill-down path still showed the older Marathon Match rating/history. Root cause (if identifiable) The active-track mapper hardcoded Data Science to Marathon Match and SRM only. It ignored the Challenge subtrack key now produced by member-api for Data Science Challenge ratings/history. What was changed Added DATA_SCIENCE.Challenge to the profile stats and history models, included it as an active Data Science subtrack, and changed the Data Science summary card to use the strongest visible Data Science subtrack rating instead of always using Marathon Match. Any added/updated tests Added getActiveTracks coverage for a member with Data Science Challenge and Marathon Match stats to verify the Challenge subtrack is included and its newer rating drives the Data Science summary. --- .../src/hooks/useFetchActiveTracks.spec.tsx | 33 +++++++++++++++ .../src/hooks/useFetchActiveTracks.tsx | 40 +++++++++++++++---- src/libs/core/lib/profile/user-stats.model.ts | 4 ++ 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx index 1023542d2..12bdfaeba 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx @@ -74,6 +74,39 @@ describe('getActiveTracks', () => { .toEqual(['Challenge', 'Task']) }) + it('includes Data Science Challenge stats in the Data Science track', () => { + const activeTracks: MemberStatsTrack[] = getActiveTracks({ + DATA_SCIENCE: { + Challenge: { + challenges: 1, + rank: { + rating: 1499, + }, + wins: 1, + }, + MARATHON_MATCH: { + challenges: 1, + rank: { + percentile: 20, + rating: 763, + }, + wins: 0, + }, + }, + } as UserStats) + const dataScienceTrack: MemberStatsTrack | undefined = activeTracks + .find(track => track.name === 'Data Science') + + expect(dataScienceTrack?.challenges) + .toEqual(2) + expect(dataScienceTrack?.wins) + .toEqual(1) + expect(dataScienceTrack?.rating) + .toEqual(1499) + expect(dataScienceTrack?.subTracks.map(track => track.name)) + .toEqual(['Challenge', 'MARATHON_MATCH']) + }) + it('keeps legacy testing subtracks in the testing track', () => { const activeTracks: MemberStatsTrack[] = getActiveTracks({ DEVELOP: { diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx index 34628707b..5317a6cff 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx @@ -74,6 +74,26 @@ const isTestingSubTrack = (subTrack?: MemberStats): boolean => ( !!subTrack?.name && testingSubTrackNames.has(subTrack.name) ) +/** + * Pick the Data Science subtrack rating used by the summary card. + * + * Data Science can include Marathon Match and challenge ratings. The summary + * should show the strongest visible rating instead of always using Marathon + * Match, otherwise Data Science Challenge ratings are hidden from the profile. + * + * @param {MemberStats[]} subTracks - Active Data Science subtracks. + * @returns {MemberStats | undefined} The subtrack with the highest rating. + */ +const getDataScienceSummarySubTrack = (subTracks: MemberStats[]): MemberStats | undefined => orderBy( + subTracks, + [ + subTrack => subTrack.rank?.rating ?? 0, + subTrack => subTrack.rank?.percentile ?? 0, + subTrack => subTrack.challenges ?? 0, + ], + ['desc', 'desc', 'desc'], +)[0] + /** * Attach parent track metadata to legacy design/develop subtracks and index them by name. * @@ -158,6 +178,13 @@ const enhanceDesignTrackData = (trackData: MemberStatsTrack): MemberStatsTrack = export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => { // Create mappings for data science subtracks const dataScienceSubTracks: {[key: string]: MemberStats | SRMStats} = { + // Map Challenge subtrack + Challenge: (memberStats?.DATA_SCIENCE?.Challenge && ({ + ...memberStats.DATA_SCIENCE.Challenge, + name: 'Challenge', + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', + })) as MemberStats, // Map MARATHON_MATCH subtrack MARATHON_MATCH: (memberStats?.DATA_SCIENCE?.MARATHON_MATCH && ({ ...memberStats.DATA_SCIENCE.MARATHON_MATCH, @@ -215,19 +242,18 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => // Data science const dsSubTracks: MemberStats[] = [ + dataScienceSubTracks.Challenge, dataScienceSubTracks.MARATHON_MATCH, ].filter(d => d?.challenges > 0) as MemberStats[] + const dsTrackData: MemberStatsTrack = buildTrackData('Data Science', dsSubTracks) + const dsSummarySubTrack: MemberStats | undefined = getDataScienceSummarySubTrack(dsTrackData.subTracks) const dsTrackStats: MemberStatsTrack = { - challenges: dataScienceSubTracks.MARATHON_MATCH?.challenges ?? 0, - isActive: (dataScienceSubTracks.MARATHON_MATCH?.challenges ?? 0) > 0, + ...dsTrackData, isDSTrack: true, - name: 'Data Science', order: -1, - percentile: dataScienceSubTracks.MARATHON_MATCH?.rank?.percentile ?? 0, - rating: dataScienceSubTracks.MARATHON_MATCH?.rank?.rating ?? 0, - subTracks: dsSubTracks, - wins: dataScienceSubTracks.MARATHON_MATCH?.wins ?? 0, + percentile: dsSummarySubTrack?.rank?.percentile ?? 0, + rating: dsSummarySubTrack?.rank?.rating ?? 0, } // Competitive Programming diff --git a/src/libs/core/lib/profile/user-stats.model.ts b/src/libs/core/lib/profile/user-stats.model.ts index 93cba462c..bfafbdab5 100644 --- a/src/libs/core/lib/profile/user-stats.model.ts +++ b/src/libs/core/lib/profile/user-stats.model.ts @@ -80,6 +80,7 @@ export type UserStats = { reposts: number } DATA_SCIENCE?: { + Challenge?: MemberStats MARATHON_MATCH: MemberStats SRM: SRMStats challenges: number @@ -127,6 +128,9 @@ export type UserStatsHistory = { }> } DATA_SCIENCE?: { + Challenge?: { + history: Array + }, SRM?: { history: Array }, From eb9c7320091793b34849524cb5102d6467143fa9 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 15 Jun 2026 12:17:00 +1000 Subject: [PATCH 30/73] PM-5257: Keep mobile rating data in one row What was broken The previous follow-up fix made the small-screen rating card fit the viewport, but it preserved the one-column mobile layout. On iOS Safari/iPhone 15 Pro Max, the rating, percentile, audience label, and help link still appeared vertically stacked instead of aligned like the AI Ratings layout. Root cause PR #1903 changed the small-screen rating card to one grid column, and PR #1923 kept that layout while only correcting the card width and box sizing. The width guard was useful, but the one-column override caused the remaining QA failure. What was changed Kept the small-screen border-box sizing and 300px rendered width cap, then restored the rating card to three columns at the small breakpoint with a flexible final column. This keeps the card within the mobile content width while aligning the rating data in one row. Any added/updated tests No tests were added because this is a scoped SCSS layout fix. Existing profile rating tests were run, along with lint, build, generated CSS verification, and the full test command. The full test command still fails in unrelated work and wallet suites outside this stylesheet change. --- .../MemberRatingCard/MemberRatingCard.module.scss | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss index f72253ab4..f0528c09e 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss @@ -97,25 +97,12 @@ @include ltesm { box-sizing: border-box; - grid-template-columns: 1fr; + grid-template-columns: 52px 104px minmax(0, 1fr); column-gap: $sp-3; - row-gap: $sp-1; justify-content: start; justify-items: stretch; max-width: 300px; width: 100%; - - .percentileWrap { - .name { - margin-left: 0; - } - } - - .link { - grid-column: 1; - justify-self: start; - margin-top: 0; - } } } } From f233c6651098167402f2ddb68663ea71903d65c7 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 15 Jun 2026 17:11:59 +1000 Subject: [PATCH 31/73] PM-5349: allow Talent Managers to access Talent tab What was broken QA reported that Talent Manager role users still could not access the new reports Talent page. Root cause The previous UI fix added the Talent page but kept the Talent tab visibility and page fetch/redirect gate administrator-only, so Talent Managers allowed into the reports app were still blocked from the Talent experience. What was changed Added a shared Talent report access utility for administrator and Talent Manager roles. The reports nav and Talent page now use that utility so both roles can see and load the Talent report. Any added/updated tests Added talent-access.utils.spec.ts coverage for administrator, Talent Manager, and denied user roles. --- .../src/lib/components/NavTabs/NavTabs.tsx | 10 ++++----- src/apps/reports/src/lib/utils/index.ts | 2 ++ .../src/lib/utils/talent-access.utils.spec.ts | 17 ++++++++++++++ .../src/lib/utils/talent-access.utils.ts | 14 ++++++++++++ .../reports/src/pages/talent/TalentPage.tsx | 22 +++++-------------- 5 files changed, 44 insertions(+), 21 deletions(-) create mode 100644 src/apps/reports/src/lib/utils/talent-access.utils.spec.ts create mode 100644 src/apps/reports/src/lib/utils/talent-access.utils.ts diff --git a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx index 602777c7c..ad5d15c5e 100644 --- a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx +++ b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx @@ -15,7 +15,6 @@ import classNames from 'classnames' import { useClickOutside } from '~/libs/shared/lib/hooks' import { TabsNavItem } from '~/libs/ui' -import { UserRole } from '~/libs/core' import { billingAccountsPageRouteId, @@ -24,6 +23,7 @@ import { talentPageRouteId, } from '../../../config/routes.config' import { ReportsAppContext, ReportsAppContextModel } from '../../contexts' +import { canAccessTalentReport } from '../../utils' import styles from './NavTabs.module.scss' @@ -33,8 +33,8 @@ const NavTabs: FC = () => { const triggerRef = useRef(null) const { pathname }: { pathname: string } = useLocation() const { loginUserInfo }: ReportsAppContextModel = useContext(ReportsAppContext) - const isAdministrator = useMemo(() => ( - !!loginUserInfo?.roles?.some(role => role.toLowerCase() === UserRole.administrator) + const canAccessTalent = useMemo(() => ( + canAccessTalentReport(loginUserInfo?.roles) ), [loginUserInfo]) const tabs = useMemo(() => { @@ -53,7 +53,7 @@ const NavTabs: FC = () => { }, ] - return isAdministrator + return canAccessTalent ? [ ...baseTabs, { @@ -62,7 +62,7 @@ const NavTabs: FC = () => { }, ] : baseTabs - }, [isAdministrator]) + }, [canAccessTalent]) const activeTabPathName: string = useMemo(() => { const matchingTabs = tabs diff --git a/src/apps/reports/src/lib/utils/index.ts b/src/apps/reports/src/lib/utils/index.ts index 83bf5f8f3..747acdbe6 100644 --- a/src/apps/reports/src/lib/utils/index.ts +++ b/src/apps/reports/src/lib/utils/index.ts @@ -1,5 +1,7 @@ import { toast } from 'react-toastify' +export { canAccessTalentReport } from './talent-access.utils' + /** * Handles API errors by extracting the most useful message and showing a toast. * @param error Axios error-like object. diff --git a/src/apps/reports/src/lib/utils/talent-access.utils.spec.ts b/src/apps/reports/src/lib/utils/talent-access.utils.spec.ts new file mode 100644 index 000000000..5156402a8 --- /dev/null +++ b/src/apps/reports/src/lib/utils/talent-access.utils.spec.ts @@ -0,0 +1,17 @@ +import { canAccessTalentReport } from './talent-access.utils' + +describe('talent-access utils', () => { + it('allows administrators and Talent Managers to access the Talent report', () => { + expect(canAccessTalentReport(['administrator'])) + .toBe(true) + expect(canAccessTalentReport([' Talent Manager '])) + .toBe(true) + }) + + it('denies users without a Talent report role', () => { + expect(canAccessTalentReport(['Topcoder User'])) + .toBe(false) + expect(canAccessTalentReport(undefined)) + .toBe(false) + }) +}) diff --git a/src/apps/reports/src/lib/utils/talent-access.utils.ts b/src/apps/reports/src/lib/utils/talent-access.utils.ts new file mode 100644 index 000000000..572399150 --- /dev/null +++ b/src/apps/reports/src/lib/utils/talent-access.utils.ts @@ -0,0 +1,14 @@ +const talentReportRoles = new Set([ + 'administrator', + 'talent manager', +]) + +/** + * Checks whether a user role list can access the reports Talent tab. + * @param roles Roles from the authenticated Topcoder profile or decoded token. + * @returns Whether the user is an administrator or Talent Manager. + */ +export function canAccessTalentReport(roles?: string[]): boolean { + return !!roles?.some(role => talentReportRoles.has(role.trim() + .toLowerCase())) +} diff --git a/src/apps/reports/src/pages/talent/TalentPage.tsx b/src/apps/reports/src/pages/talent/TalentPage.tsx index 8878fa848..3dcb72325 100644 --- a/src/apps/reports/src/pages/talent/TalentPage.tsx +++ b/src/apps/reports/src/pages/talent/TalentPage.tsx @@ -13,7 +13,6 @@ import { Navigate } from 'react-router-dom' import { EnvironmentConfig } from '~/config' import { ReportsAppContext, ReportsAppContextModel } from '~/apps/reports/src/lib' import { Pagination } from '~/apps/admin/src/lib' -import { UserRole } from '~/libs/core' import { Button, IconOutline, @@ -30,7 +29,7 @@ import { OpenToWorkTalentResponse, OpenToWorkTalentRoleCount, } from '../../lib/services' -import { handleError } from '../../lib/utils' +import { canAccessTalentReport, handleError } from '../../lib/utils' import { reportsPageRouteId, rootRoute } from '../../config/routes.config' import { @@ -76,15 +75,6 @@ const availabilityOptions: AvailabilityOption[] = [ { label: 'Part-time', value: 'PART_TIME' }, ] -/** - * Returns true when the loaded token belongs to an administrator. - * @param roles Roles from the decoded Topcoder token. - * @returns Whether the role list includes the administrator role. - */ -function hasAdministratorRole(roles?: string[]): boolean { - return !!roles?.some(role => role.toLowerCase() === UserRole.administrator) -} - /** * Builds the conic-gradient background used by the role summary chart. * @param segments Role count segments with colors and percentages. @@ -121,7 +111,7 @@ function formatMemberName(member: OpenToWorkTalentMember): string { } /** - * Admin-only Talent report page for open-to-work members. + * Administrator and Talent Manager report page for open-to-work members. * * It fetches preferred-role aggregates, renders a role-filterable member list, * and downloads the matching CSV export from reports-api. @@ -130,7 +120,7 @@ function formatMemberName(member: OpenToWorkTalentMember): string { const TalentPage: FC = () => { const { loginUserInfo }: ReportsAppContextModel = useContext(ReportsAppContext) const isAuthLoaded = loginUserInfo !== undefined - const isAdministrator = hasAdministratorRole(loginUserInfo?.roles) + const canAccessTalent = canAccessTalentReport(loginUserInfo?.roles) const [selectedRole, setSelectedRole] = useState(undefined) const [availability, setAvailability] = useState(undefined) @@ -166,7 +156,7 @@ const TalentPage: FC = () => { } members` useEffect(() => { - if (!isAdministrator) { + if (!canAccessTalent) { return undefined } @@ -194,7 +184,7 @@ const TalentPage: FC = () => { return () => { cancelled = true } - }, [availability, isAdministrator, page, perPage, refreshKey, selectedRole]) + }, [availability, canAccessTalent, page, perPage, refreshKey, selectedRole]) useEffect(() => { if (page > totalPages) { @@ -244,7 +234,7 @@ const TalentPage: FC = () => { } }, [availability, selectedRole]) - if (isAuthLoaded && !isAdministrator) { + if (isAuthLoaded && !canAccessTalent) { return } From 9bff6f9f3dbd91f066669d47f71a1f6232c0acb0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 16 Jun 2026 16:09:57 +1000 Subject: [PATCH 32/73] Set new terms UUID for 2026 --- .../constants/challenge-editor.constants.ts | 6 ++++++ .../challenges/ChallengeEditorPage/README.md | 2 +- .../components/TermsField/TermsField.spec.tsx | 15 ++++++++++++++ .../components/TermsField/TermsField.tsx | 20 +++++++++++++++++-- src/config/environments/default.env.ts | 2 ++ .../environments/global-config.model.ts | 1 + 6 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/apps/work/src/lib/constants/challenge-editor.constants.ts b/src/apps/work/src/lib/constants/challenge-editor.constants.ts index bf137422f..03920da20 100644 --- a/src/apps/work/src/lib/constants/challenge-editor.constants.ts +++ b/src/apps/work/src/lib/constants/challenge-editor.constants.ts @@ -1,3 +1,5 @@ +import { EnvironmentConfig } from '../../../../../config' + export const SPECIAL_CHALLENGE_TAGS: string[] = [ 'Marathon Match', 'Rapid Development Match', @@ -58,6 +60,10 @@ export const CHALLENGE_TYPES_WITH_MULTIPLE_PRIZES = [ export const DEFAULT_NDA_UUID = 'e5811a7b-43d1-407a-a064-69e5015b4900' +export const DEFAULT_STANDARD_TERMS_UUID = process.env.REACT_APP_DEFAULT_STANDARD_TERMS_UUID + || process.env.DEFAULT_STANDARD_TERMS_UUID + || EnvironmentConfig.DEFAULT_STANDARD_TERMS_UUID + export const ROUND_TYPES = { SINGLE_ROUND: 'Single round', TWO_ROUNDS: 'Two rounds', diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 53b58a1c7..4e5b1f646 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -69,7 +69,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `MaximumSubmissionsField`: non-visual compatibility field that rewrites the legacy `submissionLimit` metadata to the unlimited-only payload so design challenges no longer expose submission-cap controls. It defers dirtying that automatic normalization until the editor finishes its initial resource hydration, including the first render after asynchronously loaded challenge details arrive, which preserves copilot restoration before autosave/manual-save starts treating the metadata rewrite as a user change. - `ChallengeDescriptionField`: public markdown spec editor. - `ChallengePrivateDescriptionField`: optional private markdown spec editor. -- `TermsField`: advanced-option multi-select for challenge terms. The create route seeds the standard Topcoder terms entry automatically once the terms list loads, including immediately after the first draft-creation step assigns a challenge id, so the editor matches legacy work-manager defaults while still allowing the NDA toggle to add or remove the NDA term separately. +- `TermsField`: advanced-option multi-select for challenge terms. The create route seeds the standard Topcoder terms entry automatically once the terms list loads, including immediately after the first draft-creation step assigns a challenge id, so the editor matches legacy work-manager defaults while still allowing the NDA toggle to add or remove the NDA term separately. The seeded Standard Terms UUID can be set with `REACT_APP_DEFAULT_STANDARD_TERMS_UUID`, `DEFAULT_STANDARD_TERMS_UUID`, or `EnvironmentConfig.DEFAULT_STANDARD_TERMS_UUID`; legacy IDs and normalized titles are only fallback matching. - `ChallengeTagsField`: multi creatable tag picker excluding special challenge tags. - `ChallengeSkillsField`: async multi skills picker with billing-account-based required behavior. - `ChallengePrizesField`: placement-prize editor with an inline USD/POINTS selector that uses the challenge editor's green selected state, keeps the `Challenge Prizes` header on one line, and stays right-aligned above the fixed-width prize inputs. Each row always shows a numbered `Prize X` label, multi-prize setups allow tied lower placements while still rejecting prize increases for lower places, focused prize inputs keep focus while consecutive digits are entered, older payloads that omit the placement set are hydrated on demand, and only removable rows render the delete action so the first prize stays aligned with the selector. diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.spec.tsx index 772298c75..f62b3e388 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.spec.tsx @@ -28,6 +28,7 @@ jest.mock('../../../../../lib/hooks', () => ({ const mockedUseFetchTerms = useFetchTerms as jest.MockedFunction const STANDARD_TERM_ID = 'standard-terms-id' +const CONFIGURED_STANDARD_TERM_ID = 'configured-standard-terms-id' const baseDefaultValues: ChallengeEditorFormData = { description: 'Public challenge specification', @@ -103,6 +104,20 @@ describe('TermsField', () => { .toBe(STANDARD_TERM_ID) }) + it('prefers the configured standard term id over legacy matches', () => { + expect(findDefaultStandardTermId([ + { + id: STANDARD_TERM_ID, + title: 'Standard Terms v3', + }, + { + id: CONFIGURED_STANDARD_TERM_ID, + title: 'Updated Standard Terms', + }, + ], CONFIGURED_STANDARD_TERM_ID)) + .toBe(CONFIGURED_STANDARD_TERM_ID) + }) + it('defaults the standard term for new challenges', async () => { render( , diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.tsx index 5688d907e..8272fd877 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.tsx @@ -12,7 +12,10 @@ import { FormSelectField, FormSelectOption, } from '../../../../../lib/components/form' -import { DEFAULT_NDA_UUID } from '../../../../../lib/constants/challenge-editor.constants' +import { + DEFAULT_NDA_UUID, + DEFAULT_STANDARD_TERMS_UUID, +} from '../../../../../lib/constants/challenge-editor.constants' import { useFetchTerms, UseFetchTermsResult, @@ -44,7 +47,20 @@ function normalizeTermTitle(value: string): string { .toLowerCase() } -export function findDefaultStandardTermId(terms: Term[]): string | undefined { +export function findDefaultStandardTermId( + terms: Term[], + configuredDefaultTermId: string | undefined = DEFAULT_STANDARD_TERMS_UUID, +): string | undefined { + const normalizedConfiguredDefaultTermId = configuredDefaultTermId?.trim() + + if ( + normalizedConfiguredDefaultTermId + && terms.some(term => term.id === normalizedConfiguredDefaultTermId) + ) { + return normalizedConfiguredDefaultTermId + } + + // Keep legacy discovery as a fallback until every environment has an explicit default UUID. return terms.find(term => ( LEGACY_DEFAULT_STANDARD_TERM_IDS.has(term.id) || DEFAULT_STANDARD_TERM_TITLES.has(normalizeTermTitle(term.title)) diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index d29f0f7a8..a26dbd58f 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -138,6 +138,8 @@ export const TERMS_URL = 'https://www.topcoder-dev.com/challenges/terms/detail/317cd8f9-d66c-4f2a-8774-63c612d99cd4' export const NDA_TERMS_URL = 'https://www.topcoder-dev.com/challenges/terms/detail/e5811a7b-43d1-407a-a064-69e5015b4900' +export const DEFAULT_STANDARD_TERMS_UUID = '0a507fb7-3fe0-402b-b121-1a24af4a9cf1' + export const PRIVACY_POLICY_URL = `${TOPCODER_URL}/policy` export const GAMIFICATION_ORG_ID = getReactEnv( diff --git a/src/config/environments/global-config.model.ts b/src/config/environments/global-config.model.ts index 0f2706c12..885382547 100644 --- a/src/config/environments/global-config.model.ts +++ b/src/config/environments/global-config.model.ts @@ -56,6 +56,7 @@ export interface GlobalConfig { } TERMS_URL?: string NDA_TERMS_URL?: string + DEFAULT_STANDARD_TERMS_UUID?: string MEMBER_VERIFY_LOOKER: number ENABLE_TCA_CERT_MONETIZATION: boolean VANILLA_FORUM: { From d63dd473af9583154f637df08699cba258478a10 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 17 Jun 2026 12:15:47 +1000 Subject: [PATCH 33/73] PM-5378: Allow Design phase shortening in schedule editor What was broken The schedule editor used the same active-phase end-date floor for every track, so Design users could not shorten an active phase from the review UI. Root cause The UI did not distinguish Design active phases from other active phases when calculating the minimum allowed phase end date. What was changed Added Design-track-aware end-date validation in the challenge schedule section. Active Design phases can now be shortened no earlier than the current date/time, while active non-Design phases keep the existing scheduled end date as the minimum. Any added/updated tests Updated ChallengeScheduleSection tests for active Design and non-Design minimum end-date behavior, and documented the schedule editor rule. --- .../challenges/ChallengeEditorPage/README.md | 2 +- ...hallengeScheduleSection.component.spec.tsx | 90 +++++++++- .../ChallengeScheduleSection.tsx | 166 +++++++++++++++++- 3 files changed, 249 insertions(+), 9 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 10a767044..3d618de1b 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -60,7 +60,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `ChallengeNameField`: text input. - `ChallengeTrackField`: track selector from `useFetchChallengeTracks`. - `ChallengeTypeField`: active type selector from `useFetchChallengeTypes`, excluding `Topgear Task` because that flow is not launchable from the work app editor. When the selected track is Design or QA, it also hides `Marathon Match` to match the legacy work-manager create flow and clears any now-invalid preselection. -- `ChallengeScheduleSection`: schedule editor for challenge start and phase dates. It keeps the detected timezone above the controls, renders the `Start Date` label with the `Scheduled` and `Immediately` start-mode radios aligned to the end of that header row above the input with a green selected state, persists the selected start mode in challenge metadata so saved `/edit` and `/view` routes reopen with the correct radio state, recalculates root phase dates when the challenge start changes, honors a completed predecessor phase's actual end date when deriving successor schedule rows, and keeps completed phases' end-date and duration controls locked to match legacy work-manager behavior. `Task` challenges hide this editable section across create, edit, and read-only view routes to match legacy work-manager behavior. +- `ChallengeScheduleSection`: schedule editor for challenge start and phase dates. It keeps the detected timezone above the controls, renders the `Start Date` label with the `Scheduled` and `Immediately` start-mode radios aligned to the end of that header row above the input with a green selected state, persists the selected start mode in challenge metadata so saved `/edit` and `/view` routes reopen with the correct radio state, recalculates root phase dates when the challenge start changes, honors a completed predecessor phase's actual end date when deriving successor schedule rows, lets active Design phases be shortened no earlier than the current date/time, prevents active non-Design phases from being shortened, and keeps completed phases' end-date and duration controls locked to match legacy work-manager behavior. `Task` challenges hide this editable section across create, edit, and read-only view routes to match legacy work-manager behavior. - `DesignWorkTypeField`: shown for Design + Challenge, with the legacy work-type options (`Application Front-End Design`, `Print/Presentation`, `Web Design`, `Widget or Mobile Screen Design`, `Wireframes`). The selected value is stored in challenge tags. - `FunChallengeField`: shown for `Marathon Match` type and remains editable after creation so the form can switch between fun-challenge and standard marathon-match fields. - `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. On the human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. Design challenge manual reviewers always keep the public review opportunity checkbox disabled and unchecked. diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.component.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.component.spec.tsx index cc50cac1f..e0bcff754 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.component.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.component.spec.tsx @@ -127,6 +127,11 @@ jest.mock('../../../../../lib/utils', () => ({ : String(metadataEntry.value) }, getPhaseDuration: () => 0, + getPhaseEndDateInDate: ( + startDate: Date | string, + durationMinutes: number, + ): Date => new Date(new Date(startDate) + .getTime() + durationMinutes * 60 * 1000), setMetadataValue: ( metadata: Array<{ name?: string @@ -188,6 +193,7 @@ interface TestHarnessProps { metadata?: ChallengeEditorFormData['metadata'] phases?: ChallengeEditorFormData['phases'] startDate?: ChallengeEditorFormData['startDate'] + trackId?: string } const StartDateValue = (): JSX.Element => { @@ -235,7 +241,7 @@ const TestHarness = (props: TestHarnessProps): JSX.Element => { phases: props.phases || [], reviewers: [], startDate: props.startDate, - trackId: '', + trackId: props.trackId || '', }, }) @@ -515,6 +521,88 @@ describe('ChallengeScheduleSection component', () => { })) }) + it('uses the current date as the minimum end date for active Design phases', () => { + mockUseFetchChallengeTracks.mockReturnValue({ + tracks: [{ + id: 'design-track', + name: 'Design', + track: 'DESIGN', + }], + }) + + render( + , + ) + + const reviewRow = [...mockPhaseEditorRow.mock.calls] + .map(([props]) => props as { + minEndDate?: Date + phase?: { + name?: string + } + }) + .reverse() + .find(props => props.phase?.name === 'Review') + + expect(reviewRow?.minEndDate?.toISOString()) + .toBe('2026-03-31T12:34:00.000Z') + }) + + it('uses the current scheduled end date as the minimum end date for active non-Design phases', () => { + mockUseFetchChallengeTracks.mockReturnValue({ + tracks: [{ + id: 'development-track', + name: 'Development', + track: 'DEVELOPMENT', + }], + }) + + render( + , + ) + + const reviewRow = [...mockPhaseEditorRow.mock.calls] + .map(([props]) => props as { + minEndDate?: Date + phase?: { + name?: string + } + }) + .reverse() + .find(props => props.phase?.name === 'Review') + + expect(reviewRow?.minEndDate?.toISOString()) + .toBe('2026-04-02T12:34:00.000Z') + }) + it('uses a completed predecessor actual end date for the submission row start time', () => { render( ): Date | undefined { + return dates.reduce((latestDate, date) => { + if (!date) { + return latestDate + } + + if (!latestDate || date.getTime() > latestDate.getTime()) { + return date + } + + return latestDate + }, undefined) +} + +/** + * Resolves the minimum allowed phase end date for schedule edits. + * + * @param phase phase currently being edited. + * @param phaseStartDate resolved phase start date. + * @param isDesignTrackChallenge whether the challenge is in the Design track. + * @param minScheduleDate current schedule floor used by the editor. + * @returns minimum allowed end date for the phase. + */ +function getMinimumPhaseEndDate( + phase: ChallengePhase, + phaseStartDate: Date | undefined, + isDesignTrackChallenge: boolean, + minScheduleDate: Date, +): Date | undefined { + if (phase.isOpen) { + const currentEndDate = toDate(phase.scheduledEndDate) + + return getLatestDate( + isDesignTrackChallenge + ? [ + phaseStartDate, + minScheduleDate, + ] + : [ + currentEndDate, + phaseStartDate, + minScheduleDate, + ], + ) + } + + return phaseStartDate || minScheduleDate +} + +/** + * Resolves the validation message for edits before the phase end-date minimum. + * + * @param phase phase currently being edited. + * @param isDesignTrackChallenge whether the challenge is in the Design track. + * @returns validation message shown below the phase end-date control. + */ +function getMinimumPhaseEndDateError( + phase: ChallengePhase, + isDesignTrackChallenge: boolean, +): string { + if (phase.isOpen && isDesignTrackChallenge) { + return 'End date must be at or after the current date/time.' + } + + if (phase.isOpen) { + return 'Active phase end date cannot be shortened for this track.' + } + + return 'End date must be after start date.' +} + // eslint-disable-next-line complexity export const ChallengeScheduleSection: FC = ( props: ChallengeScheduleSectionProps, @@ -379,20 +457,61 @@ export const ChallengeScheduleSection: FC = ( const handleDurationChange = useCallback( (index: number, durationMinutes: number): void => { - const nextPhases = phases.map((phase, phaseIndex) => { + const phase = phases[index] + if (!phase) { + return + } + + const phaseKey = getPhaseKey(phase, index) + const phaseStartDate = toDate(phase.scheduledStartDate) + const nextDuration = normalizeDuration(durationMinutes) + const nextEndDate = phaseStartDate + ? getPhaseEndDateInDate(phaseStartDate, nextDuration) + : undefined + const minimumEndDate = getMinimumPhaseEndDate( + phase, + phaseStartDate, + isDesignTrackChallenge, + minScheduleDate, + ) + + if ( + minimumEndDate + && nextEndDate + && nextEndDate.getTime() < minimumEndDate.getTime() + ) { + setPhaseEndDateErrors(previousState => ({ + ...previousState, + [phaseKey]: getMinimumPhaseEndDateError(phase, isDesignTrackChallenge), + })) + return + } + + setPhaseEndDateErrors(previousState => { + const nextState = { ...previousState } + delete nextState[phaseKey] + return nextState + }) + + const nextPhases = phases.map((currentPhase, phaseIndex) => { if (phaseIndex !== index) { - return phase + return currentPhase } return { - ...phase, - duration: normalizeDuration(durationMinutes), + ...currentPhase, + duration: nextDuration, } }) applyPhases(nextPhases) }, - [applyPhases, phases], + [ + applyPhases, + isDesignTrackChallenge, + minScheduleDate, + phases, + ], ) const handlePhaseStartDateChange = useCallback( (index: number, date: Date | null): void => { @@ -457,6 +576,29 @@ export const ChallengeScheduleSection: FC = ( return } + const minimumEndDate = getMinimumPhaseEndDate( + phase, + phaseStartDate, + isDesignTrackChallenge, + minScheduleDate, + ) + if ( + minimumEndDate + && nextEndDate.getTime() < minimumEndDate.getTime() + ) { + setPhaseEndDateErrors(previousState => ({ + ...previousState, + [phaseKey]: getMinimumPhaseEndDateError(phase, isDesignTrackChallenge), + })) + return + } + + setPhaseEndDateErrors(previousState => { + const nextState = { ...previousState } + delete nextState[phaseKey] + return nextState + }) + const nextPhases = phases.map((currentPhase, phaseIndex) => { if (phaseIndex !== index) { return currentPhase @@ -474,7 +616,12 @@ export const ChallengeScheduleSection: FC = ( applyPhases(nextPhases) }, - [applyPhases, phases], + [ + applyPhases, + isDesignTrackChallenge, + minScheduleDate, + phases, + ], ) const handleStartDateModeChange = useCallback( @@ -675,7 +822,12 @@ export const ChallengeScheduleSection: FC = ( getPhaseKey(phase, index), )} key={phase.id || phase.phaseId || `${index}`} - minEndDate={phaseStartDate || minScheduleDate} + minEndDate={getMinimumPhaseEndDate( + phase, + phaseStartDate, + isDesignTrackChallenge, + minScheduleDate, + )} minStartDate={minScheduleDate} onDurationChange={handleDurationChange} onEndDateChange={handlePhaseEndDateChange} From 5db271a61a494636e718654dace58e0ca00f2d27 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 17 Jun 2026 13:30:20 +1000 Subject: [PATCH 34/73] Billing account display tweaks --- .../BillingAccountLineItemsModal.spec.tsx | 29 +++++++++++++++++ .../BillingAccountLineItemsModal.tsx | 20 +++++++++--- .../services/billing-accounts.service.spec.ts | 8 +++-- .../lib/services/billing-accounts.service.ts | 32 +++++++++++++++---- 4 files changed, 75 insertions(+), 14 deletions(-) diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx index 8cbd9e45e..8f54de7ef 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx @@ -377,6 +377,35 @@ describe('BillingAccountLineItemsModal', () => { ) }) + it('uses persisted engagement payment split amounts before deriving markup', () => { + renderModal({ + ...baseBillingAccountDetails, + consumedAmounts: [ + { + amount: '762.66', + challengeFee: '420.66', + date: '2026-06-02T13:10:48.235Z', + externalId: 'assignment-5245', + externalName: 'Eng BA', + externalType: 'ENGAGEMENT', + paymentAmount: '342.00', + }, + ], + consumedBudget: 762.66, + markup: 0.01226408, + totalBudgetRemaining: 237.34, + }) + + expect(screen.getByText('$342.00')) + .toBeTruthy() + expect(screen.getByText('$420.66')) + .toBeTruthy() + expect(screen.queryByText('$753.42')) + .toBeNull() + expect(screen.queryByText('$9.24')) + .toBeNull() + }) + it('builds engagement links from assignment-backed billing rows for copilot views', () => { mockedUseFetchEngagements.mockReturnValue({ engagements: [ diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx index 3aecd2cde..f35b291d2 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -371,10 +371,11 @@ function getConsumedChallengeFeeAmount( * @param displayAmount Member-payment amount selected for display. * @param billingAccountDetails Billing account detail payload containing hidden markup when available. * @param challengeDetailsById Hydrated challenge details, or `undefined` while loading. - * @returns Persisted consumed challenge fee, calculated markup fee, or + * @returns Persisted engagement or consumed challenge fee, calculated markup fee, or * `undefined` when the fee cannot be derived. - * @remarks Consumed challenge rows with an explicit member subtotal do not - * need challenge markup hydration to show the correct fee. + * @remarks Engagement payment rows can expose the exact finance split. + * Consumed challenge rows with an explicit member subtotal do not need + * challenge markup hydration to show the correct fee. */ function getLineItemChallengeFeeAmount( item: BillingAccountLineItem, @@ -382,6 +383,10 @@ function getLineItemChallengeFeeAmount( billingAccountDetails: BillingAccountDetails, challengeDetailsById: ChallengeDetailsById | undefined, ): number | undefined { + if (item.externalType === 'ENGAGEMENT' && item.challengeFee !== undefined) { + return Number(item.challengeFee.toFixed(2)) + } + const consumedChallengeFeeAmount = getConsumedChallengeFeeAmount(item) if (consumedChallengeFeeAmount !== undefined) { @@ -401,13 +406,18 @@ function getLineItemChallengeFeeAmount( * @param item Raw locked or consumed billing-account engagement line item. * @param billingAccountDetails Billing account detail payload containing markup when available. * @returns Member payment amount when it can be derived. - * @remarks Engagement rows prefer API-provided member-payment amounts. When - * only the billing-account charge is available, markup is removed once. + * @remarks Engagement rows prefer persisted finance payment amounts, then + * API-provided member-payment aliases. When only the billing-account charge + * is available, markup is removed once. */ function getEngagementMemberPaymentAmount( item: BillingAccountLineItem, billingAccountDetails: BillingAccountDetails, ): number | undefined { + if (item.paymentAmount !== undefined) { + return item.paymentAmount + } + if (item.memberPaymentAmount !== undefined) { return item.memberPaymentAmount } diff --git a/src/apps/work/src/lib/services/billing-accounts.service.spec.ts b/src/apps/work/src/lib/services/billing-accounts.service.spec.ts index dbf00f65d..93a957f8d 100644 --- a/src/apps/work/src/lib/services/billing-accounts.service.spec.ts +++ b/src/apps/work/src/lib/services/billing-accounts.service.spec.ts @@ -177,12 +177,14 @@ describe('combineBillingAccountLineItems', () => { budget: 2000, consumedAmounts: [ { - amount: '75', + amount: '762.66', + challengeFee: '420.66', date: '2026-02-12T00:00:00.000Z', engagementId: 'engagement-300', externalId: 'assignment-300', externalName: 'Engagement Assignment', externalType: 'ENGAGEMENT', + paymentAmount: '342.00', }, { amount: '75', @@ -192,7 +194,7 @@ describe('combineBillingAccountLineItems', () => { externalType: 'ENGAGEMENT', }, ], - consumedBudget: 150, + consumedBudget: 837.66, id: 80001063, lockedAmounts: [ { @@ -249,10 +251,12 @@ describe('combineBillingAccountLineItems', () => { .toHaveLength(2) expect(consumedRows[0]) .toMatchObject({ + challengeFee: 420.66, date: '2026-02-12T00:00:00.000Z', engagementId: 'engagement-300', externalId: 'assignment-300', externalType: 'ENGAGEMENT', + paymentAmount: 342, status: 'consumed', }) expect(consumedRows[1]) diff --git a/src/apps/work/src/lib/services/billing-accounts.service.ts b/src/apps/work/src/lib/services/billing-accounts.service.ts index 0d0923e90..880c5fdf7 100644 --- a/src/apps/work/src/lib/services/billing-accounts.service.ts +++ b/src/apps/work/src/lib/services/billing-accounts.service.ts @@ -25,12 +25,14 @@ export type BillingAccountExternalType = 'CHALLENGE' | 'ENGAGEMENT' export interface BillingAccountBudgetEntry { amount: number | string challengeId?: string + challengeFee?: number | string date: string engagementId?: string externalId?: string externalName: string | null externalType: BillingAccountExternalType memberPaymentAmount?: number | string + paymentAmount?: number | string } export type BillingAccountLockedAmount = BillingAccountBudgetEntry @@ -40,12 +42,14 @@ export interface BillingAccountLineItem { id: string amount: number challengeId?: string + challengeFee?: number date: string engagementId?: string externalId?: string externalName?: string | null externalType: BillingAccountExternalType memberPaymentAmount?: number + paymentAmount?: number status: BillingAccountLineItemStatus } @@ -170,8 +174,9 @@ function createLineItemKey( * @param index The entry index within its source bucket, used in the generated row key. * @returns A normalized line item with numeric amount, original date, display * fallback for nullable external names, optional canonical external id, - * optional engagement id, optional legacy challenge id, optional copilot-safe - * member payment amount, and a deterministic UI row key. + * optional engagement id, optional legacy challenge id, optional persisted + * payment split amounts, optional copilot-safe member payment amount, and a + * deterministic UI row key. */ function createLineItem( status: BillingAccountLineItemStatus, @@ -211,6 +216,18 @@ function createLineItem( lineItem.memberPaymentAmount = memberPaymentAmount } + const paymentAmount = Number(item.paymentAmount) + + if (Number.isFinite(paymentAmount)) { + lineItem.paymentAmount = paymentAmount + } + + const challengeFee = Number(item.challengeFee) + + if (Number.isFinite(challengeFee)) { + lineItem.challengeFee = challengeFee + } + return lineItem } @@ -257,10 +274,11 @@ export async function searchBillingAccounts( * Fetches a single billing account by its identifier. * * The detail payload includes budget totals plus locked and consumed external - * entries with `amount`, optional copilot-safe `memberPaymentAmount`, `date`, + * entries with `amount`, optional persisted `paymentAmount` and + * `challengeFee`, optional copilot-safe `memberPaymentAmount`, `date`, * optional canonical `externalId`, optional `engagementId`, `externalType`, - * and nullable `externalName`. Top-level `id` and `name` remain available - * for lookup labels. + * and nullable `externalName`. Top-level `id` and `name` remain available for + * lookup labels. */ export async function fetchBillingAccountById( billingAccountId: string, @@ -285,8 +303,8 @@ export async function fetchBillingAccountById( * * @param details Billing account details containing locked and consumed entry arrays. * @returns Normalized line items with numeric amounts, optional engagement - * ids, optional member payment amounts, API dates, external metadata, - * status, and UI row keys. + * ids, optional persisted payment split amounts, optional member payment + * amounts, API dates, external metadata, status, and UI row keys. */ export function combineBillingAccountLineItems( details: BillingAccountDetails, From 973c908ccc70e344958468e369826089d6ddea6a Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 17 Jun 2026 13:32:17 +1000 Subject: [PATCH 35/73] PM-5245: Use persisted engagement payment splits What was broken The billing account details modal could show the wrong member payment and challenge fee split for consumed engagement payments. Root cause Billing-account line item normalization dropped the finance paymentAmount and challengeFee fields, so engagement rows fell back to recalculating the split from billing-account markup. What was changed Preserved paymentAmount and challengeFee on billing-account line items and made engagement rows prefer those persisted finance values before deriving amounts from markup. Any added/updated tests Updated billing-account normalization coverage and added a modal regression test for a production-shaped engagement payment split. --- .../BillingAccountLineItemsModal.spec.tsx | 29 +++++++++++++++++ .../BillingAccountLineItemsModal.tsx | 20 +++++++++--- .../services/billing-accounts.service.spec.ts | 8 +++-- .../lib/services/billing-accounts.service.ts | 32 +++++++++++++++---- 4 files changed, 75 insertions(+), 14 deletions(-) diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx index 8cbd9e45e..8f54de7ef 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx @@ -377,6 +377,35 @@ describe('BillingAccountLineItemsModal', () => { ) }) + it('uses persisted engagement payment split amounts before deriving markup', () => { + renderModal({ + ...baseBillingAccountDetails, + consumedAmounts: [ + { + amount: '762.66', + challengeFee: '420.66', + date: '2026-06-02T13:10:48.235Z', + externalId: 'assignment-5245', + externalName: 'Eng BA', + externalType: 'ENGAGEMENT', + paymentAmount: '342.00', + }, + ], + consumedBudget: 762.66, + markup: 0.01226408, + totalBudgetRemaining: 237.34, + }) + + expect(screen.getByText('$342.00')) + .toBeTruthy() + expect(screen.getByText('$420.66')) + .toBeTruthy() + expect(screen.queryByText('$753.42')) + .toBeNull() + expect(screen.queryByText('$9.24')) + .toBeNull() + }) + it('builds engagement links from assignment-backed billing rows for copilot views', () => { mockedUseFetchEngagements.mockReturnValue({ engagements: [ diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx index 3aecd2cde..f35b291d2 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -371,10 +371,11 @@ function getConsumedChallengeFeeAmount( * @param displayAmount Member-payment amount selected for display. * @param billingAccountDetails Billing account detail payload containing hidden markup when available. * @param challengeDetailsById Hydrated challenge details, or `undefined` while loading. - * @returns Persisted consumed challenge fee, calculated markup fee, or + * @returns Persisted engagement or consumed challenge fee, calculated markup fee, or * `undefined` when the fee cannot be derived. - * @remarks Consumed challenge rows with an explicit member subtotal do not - * need challenge markup hydration to show the correct fee. + * @remarks Engagement payment rows can expose the exact finance split. + * Consumed challenge rows with an explicit member subtotal do not need + * challenge markup hydration to show the correct fee. */ function getLineItemChallengeFeeAmount( item: BillingAccountLineItem, @@ -382,6 +383,10 @@ function getLineItemChallengeFeeAmount( billingAccountDetails: BillingAccountDetails, challengeDetailsById: ChallengeDetailsById | undefined, ): number | undefined { + if (item.externalType === 'ENGAGEMENT' && item.challengeFee !== undefined) { + return Number(item.challengeFee.toFixed(2)) + } + const consumedChallengeFeeAmount = getConsumedChallengeFeeAmount(item) if (consumedChallengeFeeAmount !== undefined) { @@ -401,13 +406,18 @@ function getLineItemChallengeFeeAmount( * @param item Raw locked or consumed billing-account engagement line item. * @param billingAccountDetails Billing account detail payload containing markup when available. * @returns Member payment amount when it can be derived. - * @remarks Engagement rows prefer API-provided member-payment amounts. When - * only the billing-account charge is available, markup is removed once. + * @remarks Engagement rows prefer persisted finance payment amounts, then + * API-provided member-payment aliases. When only the billing-account charge + * is available, markup is removed once. */ function getEngagementMemberPaymentAmount( item: BillingAccountLineItem, billingAccountDetails: BillingAccountDetails, ): number | undefined { + if (item.paymentAmount !== undefined) { + return item.paymentAmount + } + if (item.memberPaymentAmount !== undefined) { return item.memberPaymentAmount } diff --git a/src/apps/work/src/lib/services/billing-accounts.service.spec.ts b/src/apps/work/src/lib/services/billing-accounts.service.spec.ts index dbf00f65d..93a957f8d 100644 --- a/src/apps/work/src/lib/services/billing-accounts.service.spec.ts +++ b/src/apps/work/src/lib/services/billing-accounts.service.spec.ts @@ -177,12 +177,14 @@ describe('combineBillingAccountLineItems', () => { budget: 2000, consumedAmounts: [ { - amount: '75', + amount: '762.66', + challengeFee: '420.66', date: '2026-02-12T00:00:00.000Z', engagementId: 'engagement-300', externalId: 'assignment-300', externalName: 'Engagement Assignment', externalType: 'ENGAGEMENT', + paymentAmount: '342.00', }, { amount: '75', @@ -192,7 +194,7 @@ describe('combineBillingAccountLineItems', () => { externalType: 'ENGAGEMENT', }, ], - consumedBudget: 150, + consumedBudget: 837.66, id: 80001063, lockedAmounts: [ { @@ -249,10 +251,12 @@ describe('combineBillingAccountLineItems', () => { .toHaveLength(2) expect(consumedRows[0]) .toMatchObject({ + challengeFee: 420.66, date: '2026-02-12T00:00:00.000Z', engagementId: 'engagement-300', externalId: 'assignment-300', externalType: 'ENGAGEMENT', + paymentAmount: 342, status: 'consumed', }) expect(consumedRows[1]) diff --git a/src/apps/work/src/lib/services/billing-accounts.service.ts b/src/apps/work/src/lib/services/billing-accounts.service.ts index 0d0923e90..880c5fdf7 100644 --- a/src/apps/work/src/lib/services/billing-accounts.service.ts +++ b/src/apps/work/src/lib/services/billing-accounts.service.ts @@ -25,12 +25,14 @@ export type BillingAccountExternalType = 'CHALLENGE' | 'ENGAGEMENT' export interface BillingAccountBudgetEntry { amount: number | string challengeId?: string + challengeFee?: number | string date: string engagementId?: string externalId?: string externalName: string | null externalType: BillingAccountExternalType memberPaymentAmount?: number | string + paymentAmount?: number | string } export type BillingAccountLockedAmount = BillingAccountBudgetEntry @@ -40,12 +42,14 @@ export interface BillingAccountLineItem { id: string amount: number challengeId?: string + challengeFee?: number date: string engagementId?: string externalId?: string externalName?: string | null externalType: BillingAccountExternalType memberPaymentAmount?: number + paymentAmount?: number status: BillingAccountLineItemStatus } @@ -170,8 +174,9 @@ function createLineItemKey( * @param index The entry index within its source bucket, used in the generated row key. * @returns A normalized line item with numeric amount, original date, display * fallback for nullable external names, optional canonical external id, - * optional engagement id, optional legacy challenge id, optional copilot-safe - * member payment amount, and a deterministic UI row key. + * optional engagement id, optional legacy challenge id, optional persisted + * payment split amounts, optional copilot-safe member payment amount, and a + * deterministic UI row key. */ function createLineItem( status: BillingAccountLineItemStatus, @@ -211,6 +216,18 @@ function createLineItem( lineItem.memberPaymentAmount = memberPaymentAmount } + const paymentAmount = Number(item.paymentAmount) + + if (Number.isFinite(paymentAmount)) { + lineItem.paymentAmount = paymentAmount + } + + const challengeFee = Number(item.challengeFee) + + if (Number.isFinite(challengeFee)) { + lineItem.challengeFee = challengeFee + } + return lineItem } @@ -257,10 +274,11 @@ export async function searchBillingAccounts( * Fetches a single billing account by its identifier. * * The detail payload includes budget totals plus locked and consumed external - * entries with `amount`, optional copilot-safe `memberPaymentAmount`, `date`, + * entries with `amount`, optional persisted `paymentAmount` and + * `challengeFee`, optional copilot-safe `memberPaymentAmount`, `date`, * optional canonical `externalId`, optional `engagementId`, `externalType`, - * and nullable `externalName`. Top-level `id` and `name` remain available - * for lookup labels. + * and nullable `externalName`. Top-level `id` and `name` remain available for + * lookup labels. */ export async function fetchBillingAccountById( billingAccountId: string, @@ -285,8 +303,8 @@ export async function fetchBillingAccountById( * * @param details Billing account details containing locked and consumed entry arrays. * @returns Normalized line items with numeric amounts, optional engagement - * ids, optional member payment amounts, API dates, external metadata, - * status, and UI row keys. + * ids, optional persisted payment split amounts, optional member payment + * amounts, API dates, external metadata, status, and UI row keys. */ export function combineBillingAccountLineItems( details: BillingAccountDetails, From a3826aa203a4f527568a089c1efac479cbe0a67d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 17 Jun 2026 13:36:51 +1000 Subject: [PATCH 36/73] Terms update for PM-5330 --- .../src/lib/hooks/useTermsAgreementGate.ts | 10 ++- .../src/lib/utils/terms.utils.spec.ts | 37 +++++++++ .../engagements/src/lib/utils/terms.utils.ts | 76 +++++++++++++++++++ .../application-form/ApplicationFormPage.tsx | 9 ++- 4 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 src/apps/engagements/src/lib/utils/terms.utils.spec.ts diff --git a/src/apps/engagements/src/lib/hooks/useTermsAgreementGate.ts b/src/apps/engagements/src/lib/hooks/useTermsAgreementGate.ts index c1cadd183..734c919c8 100644 --- a/src/apps/engagements/src/lib/hooks/useTermsAgreementGate.ts +++ b/src/apps/engagements/src/lib/hooks/useTermsAgreementGate.ts @@ -8,7 +8,7 @@ import { getDocuSignUrl, getTermDetails, } from '../services' -import { extractTermId } from '../utils' +import { extractTermId, resolveStandardTermsConfig } from '../utils' type TermsConfig = { id: string @@ -56,11 +56,15 @@ type TermsViewData = { isElectronicallyAgreeable: boolean } -const TERMS_ID = extractTermId(EnvironmentConfig.TERMS_URL) +const STANDARD_TERMS = resolveStandardTermsConfig( + EnvironmentConfig.DEFAULT_STANDARD_TERMS_UUID, + EnvironmentConfig.TERMS_URL, +) +const TERMS_ID = STANDARD_TERMS.id const NDA_TERMS_ID = extractTermId(EnvironmentConfig.NDA_TERMS_URL) const TERMS_CONFIG: TermsConfig[] = [ - { id: TERMS_ID ?? '', label: 'Standard Topcoder Terms', url: EnvironmentConfig.TERMS_URL }, + { id: TERMS_ID ?? '', label: 'Standard Topcoder Terms', url: STANDARD_TERMS.url }, { id: NDA_TERMS_ID ?? '', label: 'Topcoder NDA', url: EnvironmentConfig.NDA_TERMS_URL }, ].filter(term => term.id) diff --git a/src/apps/engagements/src/lib/utils/terms.utils.spec.ts b/src/apps/engagements/src/lib/utils/terms.utils.spec.ts new file mode 100644 index 000000000..5fb07547f --- /dev/null +++ b/src/apps/engagements/src/lib/utils/terms.utils.spec.ts @@ -0,0 +1,37 @@ +import { + extractTermId, + replaceTermIdInUrl, + resolveStandardTermsConfig, +} from './terms.utils' + +describe('engagement terms utils', () => { + const OLD_TERMS_ID = '317cd8f9-d66c-4f2a-8774-63c612d99cd4' + const NEW_TERMS_ID = '0a507fb7-3fe0-402b-b121-1a24af4a9cf1' + const TERMS_URL = `https://www.topcoder-dev.com/challenges/terms/detail/${OLD_TERMS_ID}` + + it('extracts the terms id from a terms detail URL', () => { + expect(extractTermId(TERMS_URL)) + .toBe(OLD_TERMS_ID) + }) + + it('rewrites a terms detail URL with the configured term id', () => { + expect(replaceTermIdInUrl(TERMS_URL, NEW_TERMS_ID)) + .toBe(`https://www.topcoder-dev.com/challenges/terms/detail/${NEW_TERMS_ID}`) + }) + + it('prefers the configured Standard Terms id over the fallback terms URL id', () => { + expect(resolveStandardTermsConfig(NEW_TERMS_ID, TERMS_URL)) + .toEqual({ + id: NEW_TERMS_ID, + url: `https://www.topcoder-dev.com/challenges/terms/detail/${NEW_TERMS_ID}`, + }) + }) + + it('falls back to the terms URL id when no configured Standard Terms id is provided', () => { + expect(resolveStandardTermsConfig(undefined, TERMS_URL)) + .toEqual({ + id: OLD_TERMS_ID, + url: TERMS_URL, + }) + }) +}) diff --git a/src/apps/engagements/src/lib/utils/terms.utils.ts b/src/apps/engagements/src/lib/utils/terms.utils.ts index e2232329b..41933ac04 100644 --- a/src/apps/engagements/src/lib/utils/terms.utils.ts +++ b/src/apps/engagements/src/lib/utils/terms.utils.ts @@ -1,3 +1,14 @@ +export type ResolvedTermsConfig = { + id?: string + url?: string +} + +/** + * Extracts the trailing terms identifier from a terms detail URL or path. + * + * @param termsUrl - Full terms URL, relative path, or plain slash-delimited terms path. + * @returns The trailing terms identifier, or undefined when the input is empty. + */ export const extractTermId = (termsUrl?: string): string | undefined => { if (!termsUrl) { return undefined @@ -19,3 +30,68 @@ export const extractTermId = (termsUrl?: string): string | undefined => { return parts[parts.length - 1] } } + +/** + * Rewrites an existing terms detail URL so it points at the supplied terms identifier. + * + * @param termsUrl - Current terms detail URL or path used as the URL template. + * @param termId - Terms identifier that should replace the trailing URL segment. + * @returns A terms detail URL for the supplied identifier, or the original URL when it cannot be rewritten. + */ +export const replaceTermIdInUrl = ( + termsUrl?: string, + termId?: string, +): string | undefined => { + const trimmedTermId = termId?.trim() + if (!termsUrl || !trimmedTermId) { + return termsUrl + } + + const trimmedTermsUrl = termsUrl.trim() + if (!trimmedTermsUrl) { + return undefined + } + + try { + const parsed = new URL(trimmedTermsUrl) + const parts = parsed.pathname.split('/') + .filter(Boolean) + + if (parts.length === 0) { + return trimmedTermsUrl + } + + parts[parts.length - 1] = trimmedTermId + parsed.pathname = `/${parts.join('/')}` + return parsed.toString() + } catch { + const parts = trimmedTermsUrl.split('/') + .filter(Boolean) + + if (parts.length === 0) { + return trimmedTermsUrl + } + + parts[parts.length - 1] = trimmedTermId + return parts.join('/') + } +} + +/** + * Resolves the Standard Terms ID and detail URL used by engagement agreement checks. + * + * @param defaultStandardTermsId - Preferred Standard Terms UUID from environment configuration. + * @param termsUrl - Fallback Standard Terms detail URL. + * @returns The resolved Standard Terms identifier and a matching detail URL. + */ +export const resolveStandardTermsConfig = ( + defaultStandardTermsId?: string, + termsUrl?: string, +): ResolvedTermsConfig => { + const id = defaultStandardTermsId?.trim() || extractTermId(termsUrl) + + return { + id, + url: replaceTermIdInUrl(termsUrl, id), + } +} diff --git a/src/apps/engagements/src/pages/application-form/ApplicationFormPage.tsx b/src/apps/engagements/src/pages/application-form/ApplicationFormPage.tsx index 89e5459bf..383314c61 100644 --- a/src/apps/engagements/src/pages/application-form/ApplicationFormPage.tsx +++ b/src/apps/engagements/src/pages/application-form/ApplicationFormPage.tsx @@ -19,7 +19,7 @@ import { getUserDataForApplication, updateUserDataForApplication, } from '../../lib/services' -import { extractTermId } from '../../lib/utils' +import { extractTermId, resolveStandardTermsConfig } from '../../lib/utils' import { rootRoute } from '../../engagements.routes' import type { ApplicationFormData, PrePopulatedUserData } from './application-form.types' @@ -50,9 +50,14 @@ const getIsSubmitDisabled = (params: SubmitDisabledParams): boolean => ( || (params.hasSubmitted && !params.isValid) ) +const STANDARD_TERMS = resolveStandardTermsConfig( + EnvironmentConfig.DEFAULT_STANDARD_TERMS_UUID, + EnvironmentConfig.TERMS_URL, +) + const TERMS_STATUS_CONFIG: TermsStatusConfig[] = [ { - id: extractTermId(EnvironmentConfig.TERMS_URL) ?? '', + id: STANDARD_TERMS.id ?? '', label: 'Standard Topcoder Terms', }, { From 8db20646bd802b0518a1dd14e5084e5d9338c78d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 17 Jun 2026 14:07:27 +1000 Subject: [PATCH 37/73] Update to NDA UUID for new NDA docusign template --- .../src/lib/hooks/useTermsAgreementGate.ts | 15 +++++--- .../src/lib/utils/terms.utils.spec.ts | 18 +++++++++ .../engagements/src/lib/utils/terms.utils.ts | 37 +++++++++++++++++++ src/config/environments/default.env.ts | 1 + .../environments/global-config.model.ts | 1 + src/config/environments/prod.env.ts | 1 + 6 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/apps/engagements/src/lib/hooks/useTermsAgreementGate.ts b/src/apps/engagements/src/lib/hooks/useTermsAgreementGate.ts index 734c919c8..30c4551f6 100644 --- a/src/apps/engagements/src/lib/hooks/useTermsAgreementGate.ts +++ b/src/apps/engagements/src/lib/hooks/useTermsAgreementGate.ts @@ -8,7 +8,11 @@ import { getDocuSignUrl, getTermDetails, } from '../services' -import { extractTermId, resolveStandardTermsConfig } from '../utils' +import { + extractTermId, + resolveDocuSignTemplateId, + resolveStandardTermsConfig, +} from '../utils' type TermsConfig = { id: string @@ -285,11 +289,12 @@ export const useTermsAgreementGate = ( () => getTermsViewData(termsDetails), [termsDetails], ) - const docuSignTemplateId = termsDetails?.docusignTemplateId + const docuSignTemplateId = resolveDocuSignTemplateId( + termsDetails, + EnvironmentConfig.NDA_DOCUSIGN_TEMPLATE_ID, + ) const isDocuSignTerm = Boolean( - termsDetails?.agreeabilityType - && termsDetails.agreeabilityType !== 'Electronically-agreeable' - && docuSignTemplateId, + termsDetails && docuSignTemplateId, ) const termsUrl = activeTerm?.url || termsDetails?.url diff --git a/src/apps/engagements/src/lib/utils/terms.utils.spec.ts b/src/apps/engagements/src/lib/utils/terms.utils.spec.ts index 5fb07547f..f1ab1c09d 100644 --- a/src/apps/engagements/src/lib/utils/terms.utils.spec.ts +++ b/src/apps/engagements/src/lib/utils/terms.utils.spec.ts @@ -1,12 +1,14 @@ import { extractTermId, replaceTermIdInUrl, + resolveDocuSignTemplateId, resolveStandardTermsConfig, } from './terms.utils' describe('engagement terms utils', () => { const OLD_TERMS_ID = '317cd8f9-d66c-4f2a-8774-63c612d99cd4' const NEW_TERMS_ID = '0a507fb7-3fe0-402b-b121-1a24af4a9cf1' + const NEW_NDA_TEMPLATE_ID = '8b101e82-87c0-42c9-8440-d922749c4076' const TERMS_URL = `https://www.topcoder-dev.com/challenges/terms/detail/${OLD_TERMS_ID}` it('extracts the terms id from a terms detail URL', () => { @@ -34,4 +36,20 @@ describe('engagement terms utils', () => { url: TERMS_URL, }) }) + + it('prefers the configured DocuSign template for NDA terms', () => { + expect(resolveDocuSignTemplateId({ + docusignTemplateId: 'old-template-id', + title: 'Topcoder Member Non-Disclosure Agreement v3.0', + }, NEW_NDA_TEMPLATE_ID)) + .toBe(NEW_NDA_TEMPLATE_ID) + }) + + it('keeps the Terms API DocuSign template for non-NDA terms', () => { + expect(resolveDocuSignTemplateId({ + docusignTemplateId: 'assignment-template-id', + title: 'Assignment Terms', + }, NEW_NDA_TEMPLATE_ID)) + .toBe('assignment-template-id') + }) }) diff --git a/src/apps/engagements/src/lib/utils/terms.utils.ts b/src/apps/engagements/src/lib/utils/terms.utils.ts index 41933ac04..499ac3d36 100644 --- a/src/apps/engagements/src/lib/utils/terms.utils.ts +++ b/src/apps/engagements/src/lib/utils/terms.utils.ts @@ -3,6 +3,13 @@ export type ResolvedTermsConfig = { url?: string } +type TermsTemplateDetails = { + docusignTemplateId?: string | number + title?: string +} + +const NDA_TITLE_PATTERN = /\bnda\b|non[-\s]?disclosure/i + /** * Extracts the trailing terms identifier from a terms detail URL or path. * @@ -95,3 +102,33 @@ export const resolveStandardTermsConfig = ( url: replaceTermIdInUrl(termsUrl, id), } } + +/** + * Checks whether a terms record represents an NDA-style agreement. + * + * @param term - Terms API details or search result payload. + * @returns true when the term title is NDA/non-disclosure related. + */ +export const isNdaTerm = (term?: TermsTemplateDetails): boolean => ( + NDA_TITLE_PATTERN.test(term?.title ?? '') +) + +/** + * Resolves the DocuSign template id for a terms record. + * + * @param term - Terms API details or search result payload. + * @param configuredNdaTemplateId - Preferred DocuSign template id for NDA terms. + * @returns The configured NDA template id for NDA terms, otherwise the Terms API template id. + */ +export const resolveDocuSignTemplateId = ( + term?: TermsTemplateDetails, + configuredNdaTemplateId?: string, +): string | number | undefined => { + const normalizedConfiguredNdaTemplateId = configuredNdaTemplateId?.trim() + + if (normalizedConfiguredNdaTemplateId && isNdaTerm(term)) { + return normalizedConfiguredNdaTemplateId + } + + return term?.docusignTemplateId +} diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index a26dbd58f..cbf2b351c 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -139,6 +139,7 @@ export const TERMS_URL export const NDA_TERMS_URL = 'https://www.topcoder-dev.com/challenges/terms/detail/e5811a7b-43d1-407a-a064-69e5015b4900' export const DEFAULT_STANDARD_TERMS_UUID = '0a507fb7-3fe0-402b-b121-1a24af4a9cf1' +export const NDA_DOCUSIGN_TEMPLATE_ID = '8b101e82-87c0-42c9-8440-d922749c4076' export const PRIVACY_POLICY_URL = `${TOPCODER_URL}/policy` diff --git a/src/config/environments/global-config.model.ts b/src/config/environments/global-config.model.ts index 885382547..ad2a38a09 100644 --- a/src/config/environments/global-config.model.ts +++ b/src/config/environments/global-config.model.ts @@ -57,6 +57,7 @@ export interface GlobalConfig { TERMS_URL?: string NDA_TERMS_URL?: string DEFAULT_STANDARD_TERMS_UUID?: string + NDA_DOCUSIGN_TEMPLATE_ID?: string MEMBER_VERIFY_LOOKER: number ENABLE_TCA_CERT_MONETIZATION: boolean VANILLA_FORUM: { diff --git a/src/config/environments/prod.env.ts b/src/config/environments/prod.env.ts index a334890d9..e63685ef0 100644 --- a/src/config/environments/prod.env.ts +++ b/src/config/environments/prod.env.ts @@ -5,6 +5,7 @@ export * from './default.env' export const TERMS_URL = 'https://www.topcoder.com/challenges/terms/detail/564a981e-6840-4a5c-894e-d5ad22e9cd6f' export const NDA_TERMS_URL = 'https://www.topcoder.com/challenges/terms/detail/c41e90e5-4d0e-4811-bd09-38ff72674490' +export const NDA_DOCUSIGN_TEMPLATE_ID = getReactEnv('NDA_DOCUSIGN_TEMPLATE_ID', undefined) export const VANILLA_FORUM = { V2_URL: 'https://vanilla.topcoder.com/api/v2', From 2962ff4d76b050144843cde230352dee583b31d0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 17 Jun 2026 16:57:13 +1000 Subject: [PATCH 38/73] PM-5382: Clip profile avatar to circular mask What was broken On mobile profiles, a square backing layer could render behind the circular user avatar. Root cause The shared profile picture component relied on border-radius and overflow to mask its image/background layers. Mobile browser rendering could allow that square layer to remain visible outside the intended circular avatar. What was changed Added a circular clip-path to the shared ProfilePicture wrapper so its image, background layer, and border are clipped to the same circular boundary. Any added/updated tests No automated tests were added because this is a browser CSS clipping fix. Existing profiles tests were run for coverage around the affected area. --- .../lib/components/profile-picture/ProfilePicture.module.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss b/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss index 201347bb3..3e2b3d54e 100644 --- a/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss +++ b/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss @@ -7,6 +7,7 @@ width: 100%; aspect-ratio: 1 / 1; border-radius: 50%; + clip-path: circle(50%); overflow: hidden; display: flex; From 4f27d8ec4c9aa188b7dc713fb80a3e5be17db7cf Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 17 Jun 2026 17:18:38 +1000 Subject: [PATCH 39/73] PM-5383: Show principal skills preview toggle What was broken The profile Principal Skills section rendered every principal skill immediately instead of matching the Figma behavior that shows the first five skills with a + more skills control. Root cause (if identifiable) MemberSkillsInfo mapped over the full principal skills array and did not track an expanded or collapsed state for that section. What was changed Limited the default Principal Skills view to five skills, added a + N more skills toggle, added a See less state, and reset the expanded state when the viewed profile changes. Any added/updated tests Added MemberSkillsInfo coverage for the collapsed principal skills preview, expansion, and collapse behavior. Targeted profile tests pass; the full repository test command still has unrelated work and wallet-admin failures outside this change. --- .../skills/MemberSkillsInfo.module.scss | 24 +++ .../skills/MemberSkillsInfo.spec.tsx | 168 ++++++++++++++++++ .../skills/MemberSkillsInfo.tsx | 39 +++- 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.spec.tsx diff --git a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.module.scss b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.module.scss index 6e8088213..c77af28d9 100644 --- a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.module.scss +++ b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.module.scss @@ -53,6 +53,30 @@ gap: $sp-2; } +.principalSkillsToggle { + @include resetBtnStyle; + + align-self: center; + color: $link-blue-dark; + cursor: pointer; + font-family: $font-roboto; + font-size: 12px; + line-height: 16px; + padding: $sp-2 0; + text-align: left; + + &:hover { + color: darken($link-blue-dark, 5); + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid $link-blue-dark; + outline-offset: $sp-1; + border-radius: $sp-1; + } +} + .additionalSkillsWrap { display: flex; flex-direction: column; diff --git a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.spec.tsx b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.spec.tsx new file mode 100644 index 000000000..c1582cfcf --- /dev/null +++ b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.spec.tsx @@ -0,0 +1,168 @@ +/* eslint-disable import/first, import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { fireEvent, render, screen } from '@testing-library/react' +import type { PropsWithChildren } from 'react' +import { MemoryRouter } from 'react-router-dom' + +type TestSkill = { + category: { + id: string + name: string + } + displayMode: { + id: string + name: string + } + id: string + levels: Array + name: string +} + +jest.mock('~/libs/core', () => ({ + getMemberSkillDetails: jest.fn(), + UserRole: { + administrator: 'administrator', + talentManager: 'talentManager', + }, + UserSkillDisplayModes: { + additional: 'additional', + principal: 'principal', + }, +}), { virtual: true }) + +jest.mock('~/libs/shared', () => { + const React = jest.requireActual('react') + + return { + GroupedSkillsUI: (): JSX.Element =>

    , + HowSkillsWorkModal: (): JSX.Element =>
    , + isSkillVerified: (): boolean => false, + SkillPill: (props: { skill: Pick }): JSX.Element => ( + {props.skill.name} + ), + useLocalStorage: (_key: string, initialValue: unknown): [unknown, (value: unknown) => void] => ( + React.useState(initialValue) + ), + } +}, { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + Button: (props: { label: string, onClick: () => void }): JSX.Element => ( + + ), + IconSolid: { + ChevronDownIcon: (): JSX.Element => , + ChevronUpIcon: (): JSX.Element => , + }, +}), { + virtual: true, +}) + +jest.mock('../../components', () => ({ + AddButton: (props: { label: string, onClick: () => void }): JSX.Element => ( + + ), + EditMemberPropertyBtn: (props: { onClick: () => void }): JSX.Element => ( + + ), + EmptySection: (props: PropsWithChildren<{ title: string }>): JSX.Element => ( +
    +

    {props.title}

    + {props.children} +
    + ), +})) + +jest.mock('../MemberProfile.context', () => ({ + useMemberProfileContext: (): { isTalentSearch: boolean } => ({ + isTalentSearch: false, + }), +})) + +jest.mock('./ModifySkillsModal', () => ({ + ModifySkillsModal: (): JSX.Element =>
    , +})) + +jest.mock('./PrincipalSkillsModal', () => ({ + PrincipalSkillsModal: (): JSX.Element =>
    , +})) + +import MemberSkillsInfo from './MemberSkillsInfo' + +/** + * Creates a principal UserSkill test fixture with stable IDs and display mode. + * + * @param name Display name for the skill. + * @param index Numeric suffix used to keep IDs deterministic. + * @returns A UserSkill fixture that can be rendered by MemberSkillsInfo. + * @throws This helper does not throw errors. + */ +function createPrincipalSkill(name: string, index: number): TestSkill { + return { + category: { + id: 'category-design', + name: 'Design', + }, + displayMode: { + id: 'display-principal', + name: 'principal', + }, + id: `skill-${index}`, + levels: [], + name, + } +} + +describe('MemberSkillsInfo', () => { + it('shows five principal skills before expanding the remaining skills', () => { + const profile = { + handle: 'tester', + skills: [ + 'AI', + 'BDD', + 'BIOS', + 'CAL', + 'Documentation', + 'NLP', + 'Security Testing', + ].map(createPrincipalSkill), + } + + render( + + + , + ) + + expect(screen.getByText('AI')) + .toBeInTheDocument() + expect(screen.getByText('Documentation')) + .toBeInTheDocument() + expect(screen.queryByText('NLP')) + .not + .toBeInTheDocument() + expect(screen.getByRole('button', { name: '+ 2 more skills' })) + .toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '+ 2 more skills' })) + + expect(screen.getByText('NLP')) + .toBeInTheDocument() + expect(screen.getByText('Security Testing')) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'See less' })) + .toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'See less' })) + + expect(screen.queryByText('NLP')) + .not + .toBeInTheDocument() + expect(screen.getByRole('button', { name: '+ 2 more skills' })) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx index 537dd80c5..be54d8c4f 100644 --- a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx +++ b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx @@ -15,6 +15,8 @@ import { ModifySkillsModal } from './ModifySkillsModal' import { PrincipalSkillsModal } from './PrincipalSkillsModal' import styles from './MemberSkillsInfo.module.scss' +const COLLAPSED_PRINCIPAL_SKILL_COUNT = 5 + interface MemberSkillsInfoProps { profile: UserProfile authProfile: UserProfile | undefined @@ -81,8 +83,14 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp setIsAdditionalSkillsExpanded, ]: [boolean, Dispatch>] = useState(false) + const [ + isPrincipalSkillsExpanded, + setIsPrincipalSkillsExpanded, + ]: [boolean, Dispatch>] = useState(false) + useEffect(() => { setIsAdditionalSkillsExpanded(false) + setIsPrincipalSkillsExpanded(false) }, [props.profile.handle]) useEffect(() => { @@ -142,6 +150,16 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp setPrincipalIntroModalVisible(false) } + /** + * Toggles Principal Skills between the five-skill preview and full list. + * + * Used by the Principal Skills more/less control on the member profile + * page. It takes no parameters, returns no value, and does not throw. + */ + function handlePrincipalSkillsToggle(): void { + setIsPrincipalSkillsExpanded(isExpanded => !isExpanded) + } + /** * Toggles visibility for the Additional Skills section from the section arrow. * @@ -164,6 +182,14 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp throw e }), [props.profile.handle]) + const visiblePrincipalSkills: UserSkill[] = isPrincipalSkillsExpanded + ? principalSkills + : principalSkills.slice(0, COLLAPSED_PRINCIPAL_SKILL_COUNT) + const hiddenPrincipalSkillCount: number = Math.max( + principalSkills.length - COLLAPSED_PRINCIPAL_SKILL_COUNT, + 0, + ) + return (
    @@ -202,7 +228,7 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp Principal Skills
    - {principalSkills.map((skill: UserSkill) => ( + {visiblePrincipalSkills.map((skill: UserSkill) => ( = (props: MemberSkillsInfoProp fetchSkillDetails={canFetchSkillDetails ? fetchSkillDetails : undefined} /> ))} + {hiddenPrincipalSkillCount > 0 && ( + + )}
    )} From 25fb24b6df3857bac8fb7375e5f8e2a5287efa7d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 17 Jun 2026 17:29:09 +1000 Subject: [PATCH 40/73] PM-5385: Left align mobile role interest text What was broken The profile header's interested-in full-time or part-time roles summary stayed visually right aligned on mobile, leaving the text offset from the left edge under the status badges. Root cause The status summary container and paragraph used right alignment styles without a mobile override. What was changed Added mobile breakpoint overrides so the status summary container aligns its content to the left and the interested-in roles text uses left text alignment on mobile while preserving the desktop layout. Any added/updated tests No tests were added for this CSS-only layout adjustment. Existing profile app tests were run for coverage of the affected area. --- .../profile-header/ProfileHeader.module.scss | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.module.scss b/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.module.scss index 1456b7593..b33247b7a 100644 --- a/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.module.scss +++ b/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.module.scss @@ -187,10 +187,18 @@ gap: $sp-4; align-items: end; max-width: 500px; + + @include ltelg { + align-items: flex-start; + } } .openToWorkSummary { text-align: right; + + @include ltelg { + text-align: left; + } } .roleText { @@ -199,4 +207,4 @@ :global(#start-hiring-form) { min-height: 380px; -} \ No newline at end of file +} From 0b93edd8c952cba073377bd2310aff871daf12a8 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 17 Jun 2026 17:38:40 +1000 Subject: [PATCH 41/73] PM-5386: Update profile rating percentile display What was broken The profile rating percentile reused the absolute rating color in both the compact card and comparison popup, even though the percentile is based on members per track rather than the absolute rating. The popup also still showed a small pyramid graphic that no longer matched the updated design. Root cause (if identifiable) The compact percentile and popup position value inherited the same rating-color styling used for absolute rating values, and the popup still rendered percentile-pyramid markup from the previous design. What was changed Kept the compact rating value color tied to rating, made the compact Top X% badge white, made the popup position value use the default black text color, and removed the popup pyramid markup and styles. Any added/updated tests Updated MemberRatingCard and MemberRatingInfoModal tests to verify the percentile text is not rating-colored inline and that the popup no longer renders the pyramid graphic. --- .../MemberRatingCard.module.scss | 1 + .../MemberRatingCard.spec.tsx | 3 + .../MemberRatingCard/MemberRatingCard.tsx | 1 - .../MemberRatingInfoModal.module.scss | 15 +--- .../MemberRatingInfoModal.spec.tsx | 43 ++-------- .../MemberRatingInfoModal.tsx | 84 +------------------ 6 files changed, 13 insertions(+), 134 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss index f0528c09e..70d5bc349 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss @@ -56,6 +56,7 @@ .percentileValue { background: rgba($tc-white, 0.06); border-radius: 2px; + color: $tc-white; font-family: $font-roboto; font-size: 14px; font-weight: $font-weight-bold; diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx index 1ae7bec0f..67033a0f6 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx @@ -119,6 +119,9 @@ describe('MemberRatingCard', () => { .toBeInTheDocument() expect(screen.getByText('Top 70%')) .toBeInTheDocument() + expect(screen.getByText('Top 70%')) + .not + .toHaveAttribute('style') expect(screen.getByText('Data Scientists')) .toBeInTheDocument() }) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx index 581c2f9c0..5a995dfb2 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx @@ -175,7 +175,6 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp >

    {percentileLabel}

    diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss index 9461a4590..c3d67d3e9 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss @@ -134,6 +134,7 @@ } .positionValue { + color: $black-100; font-family: $font-barlow-condensed; font-size: 32px; font-weight: $font-weight-medium; @@ -149,20 +150,6 @@ line-height: 16px; } -.tierPyramid { - align-items: center; - display: flex; - flex: 0 0 76px; - justify-content: center; - min-width: 0; -} - -.tierPyramidSvg { - display: block; - height: 69px; - width: 76px; -} - .sectionTitle { color: $black-100; font-family: $font-roboto; diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx index 58e028da4..f9b8cb629 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx @@ -1,7 +1,6 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ import '@testing-library/jest-dom' import type { PropsWithChildren } from 'react' -import type { RenderResult } from '@testing-library/react' import { render, screen, within } from '@testing-library/react' import type { UserProfile } from '~/libs/core' @@ -29,11 +28,6 @@ const ratingDistribution = { updatedBy: 'test', } -function getPyramidFills(container: HTMLElement): string[] { - return Array.from(container.querySelectorAll('polygon')) - .map((polygon: Element) => polygon.getAttribute('fill') ?? '') -} - jest.mock('~/libs/core', () => ({ getRatingColor: jest.fn(() => '#616BD5'), }), { @@ -62,7 +56,7 @@ jest.mock('../../../../lib', () => ({ })) describe('MemberRatingInfoModal', () => { - it('keeps the pyramid graphic in the position summary cell', () => { + it('renders the position summary without the pyramid graphic', () => { render( { expect(within(positionSummary) .getByText(/TOP\s+15%/)) .toBeInTheDocument() + expect(within(positionSummary) + .getByText(/TOP\s+15%/)) + .not + .toHaveAttribute('style') expect(positionSummary.querySelector('svg')) + .not .toBeInTheDocument() expect(screen.getByText('Where Emily ranks in the distribution')) .toBeInTheDocument() }) - - it('highlights pyramid segments from top percentile buckets', () => { - const rendered: RenderResult = render( - , - ) - - expect(getPyramidFills(rendered.container)) - .toEqual(['#616BD5', '#D4D4D4', '#D4D4D4', '#D4D4D4', '#D4D4D4']) - - rendered.rerender( - , - ) - - expect(getPyramidFills(rendered.container)) - .toEqual(['#D4D4D4', '#D4D4D4', '#D4D4D4', '#616BD5', '#D4D4D4']) - }) }) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx index 2c5696432..ed92f88df 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx @@ -35,11 +35,6 @@ interface RatingTier { tierLabel: string } -interface PyramidTierShape { - points: string - tierId: string -} - const ratingTiers: RatingTier[] = [{ color: '#555555', end: 899, @@ -103,23 +98,6 @@ const chartAxisLabels: Array<{ label: string, value: number }> = [{ value: 2200, }] -const pyramidTierShapes: PyramidTierShape[] = [{ - points: '50 0 60 16 40 16', - tierId: 'elite', -}, { - points: '36 21 64 21 70 34 30 34', - tierId: 'advanced', -}, { - points: '29 39 71 39 78 52 22 52', - tierId: 'skilled', -}, { - points: '20 57 80 57 88 70 12 70', - tierId: 'intermediate', -}, { - points: '12 75 88 75 100 91 0 91', - tierId: 'beginner', -}] - /** * Formats percentile values for the rating comparison modal. * @@ -137,7 +115,7 @@ const formatPercentile = (percentile?: number): string => ( /** * Returns the Topcoder rating tier metadata for a rating. * - * Used by MemberRatingInfoModal to color the summary, histogram, pyramid, and legend. + * Used by MemberRatingInfoModal to color the summary, histogram, and legend. * * @param {number | undefined} rating - The member rating value or a rating range start. * @returns {RatingTier} The tier metadata that matches the rating. @@ -162,39 +140,6 @@ const getRatingTierName = (rating?: number): string => ( rating === undefined ? 'Unrated' : getRatingTier(rating).tierLabel ) -/** - * Returns the pyramid tier that represents a member's top percentile. - * - * Used by MemberRatingInfoModal to place the highlighted pyramid segment by position instead of - * rating range: top 10%, top 25%, top 50%, top 75%, then everyone else. - * - * @param {number | undefined} percentile - The member's top percentile in the selected audience. - * @returns {RatingTier | undefined} The pyramid tier for the percentile, or undefined without a usable percentile. - */ -const getPyramidTierByPercentile = (percentile?: number): RatingTier | undefined => { - if (percentile === undefined || !Number.isFinite(percentile)) { - return undefined - } - - if (percentile <= 10) { - return ratingTiers[4] - } - - if (percentile <= 25) { - return ratingTiers[3] - } - - if (percentile <= 50) { - return ratingTiers[2] - } - - if (percentile <= 75) { - return ratingTiers[1] - } - - return ratingTiers[0] -} - /** * Parses the API distribution payload into ordered rating ranges. * @@ -313,7 +258,6 @@ const MemberRatingInfoModal: FC = (props: MemberRati const titleDisplayName: string = displayName .toUpperCase() const selectedRatingTier: RatingTier = getRatingTier(props.rating) - const selectedPyramidTier: RatingTier = getPyramidTierByPercentile(props.percentile) ?? selectedRatingTier const distributionRanges: RatingDistributionRange[] = useMemo(() => ( getDistributionRanges(props.ratingDistribution?.distribution) ), [props.ratingDistribution]) @@ -365,33 +309,9 @@ const MemberRatingInfoModal: FC = (props: MemberRati className={classNames(styles.summaryMetric, styles.positionMetric)} data-testid='rating-position-summary' > - -
    Position - + TOP {' '} {percentileLabel} From ff40eebcbe6142d2353d2be1161ea82819ab1587 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 17 Jun 2026 17:51:53 +1000 Subject: [PATCH 42/73] PM-5387: Show highest profile rating What was broken The compact profile rating card selected the newest rated track candidate, so a newer lower Data Science rating could appear while a higher AI Engineering rating from stats/maxRating was available. Root cause (if identifiable) The profile card treated event date as the primary selector and only used maxRating as a fallback when no track ratings were found. What was changed Changed the selector to include API maxRating and all rated tracks, choose the highest rating, and use event date only to break ties. Updated the card to call the renamed getProfileRating helper. Any added/updated tests Updated MemberRatingCard.utils tests to cover a higher AI Engineering rating beating a newer lower Data Science rating, and adjusted distribution/audience expectations for highest-rating selection. --- .../MemberRatingCard/MemberRatingCard.tsx | 4 +- .../MemberRatingCard.utils.spec.ts | 45 +++++++++---------- .../MemberRatingCard.utils.ts | 45 +++++++++++-------- 3 files changed, 49 insertions(+), 45 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx index 581c2f9c0..305415c0e 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx @@ -19,8 +19,8 @@ import { calculateTopPercentileFromDistribution, formatTopPercentile, getCompactRatingColor, - getLatestProfileRating, getPreferredRolesDisplay, + getProfileRating, getRatingAudienceLabel, getRatingDistributionQuery, parsePreferredRolesText, @@ -38,7 +38,7 @@ interface MemberRatingCardProps { const MemberRatingCard: FC = (props: MemberRatingCardProps) => { const memberStats: UserStats | undefined = useMemberStats(props.profile.handle) - const rating: number | undefined = useMemo(() => getLatestProfileRating(memberStats), [memberStats]) + const rating: number | undefined = useMemo(() => getProfileRating(memberStats), [memberStats]) const compactRatingColor: string = getCompactRatingColor(rating, getRatingColor(rating)) const [isInfoModalOpen, setIsInfoModalOpen]: [boolean, Dispatch>] = useState(false) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts index 30eaba29d..abeda1c63 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts @@ -4,8 +4,8 @@ import { calculateTopPercentileFromDistribution, formatTopPercentile, getCompactRatingColor, - getLatestProfileRating, getPreferredRolesDisplay, + getProfileRating, getRatingAudienceLabel, getRatingDistributionQuery, parsePreferredRolesText, @@ -29,41 +29,38 @@ describe('getCompactRatingColor', () => { }) }) -describe('getLatestProfileRating', () => { - it('uses the newest rated track instead of the historical max rating', () => { - expect(getLatestProfileRating({ +describe('getProfileRating', () => { + it('uses the highest rated track instead of the newest lower rating', () => { + expect(getProfileRating({ DATA_SCIENCE: { 'AI Engineering': { mostRecentEventDate: 1000, rank: { - rating: 840, + rating: 1200, }, }, - }, - DEVELOP: { - subTracks: [{ + Challenge: { mostRecentEventDate: 2000, - name: 'Challenge', rank: { - rating: 748, + rating: 732, }, - }], + }, }, maxRating: { - rating: 840, - ratingColor: '#9D9FA0', + rating: 1200, + ratingColor: '#616BD5', subTrack: 'AI Engineering', track: 'DATA_SCIENCE', }, } as unknown as UserStats)) - .toBe(748) + .toBe(1200) }) - it('uses configured data science rating paths when they are newest', () => { - expect(getLatestProfileRating({ + it('uses configured data science rating paths when they are highest', () => { + expect(getProfileRating({ DATA_SCIENCE: { AI: { - mostRecentEventDate: 2000, + mostRecentEventDate: 1000, rank: { rating: 840, }, @@ -71,7 +68,7 @@ describe('getLatestProfileRating', () => { }, DEVELOP: { subTracks: [{ - mostRecentEventDate: 1000, + mostRecentEventDate: 2000, name: 'Challenge', rank: { rating: 748, @@ -89,7 +86,7 @@ describe('getLatestProfileRating', () => { }) it('falls back to maxRating when no rated track entries are available', () => { - expect(getLatestProfileRating({ + expect(getProfileRating({ DEVELOP: { subTracks: [{ mostRecentEventDate: 2000, @@ -200,7 +197,7 @@ describe('getRatingAudienceLabel', () => { .toBe('QA Professionals') }) - it('uses the latest rating track for the audience label', () => { + it('uses the highest rating track for the audience label', () => { expect(getRatingAudienceLabel({ DATA_SCIENCE: { SRM: { @@ -226,7 +223,7 @@ describe('getRatingAudienceLabel', () => { track: 'DATA_SCIENCE', }, } as unknown as UserStats)) - .toBe('Developers') + .toBe('Data Scientists') }) }) @@ -246,7 +243,7 @@ describe('getRatingDistributionQuery', () => { }) }) - it('uses the latest rating track and subtrack for distribution lookup', () => { + it('uses the highest rating track and subtrack for distribution lookup', () => { expect(getRatingDistributionQuery({ DATA_SCIENCE: { SRM: { @@ -273,8 +270,8 @@ describe('getRatingDistributionQuery', () => { }, } as unknown as UserStats)) .toEqual({ - subTrack: 'Challenge', - track: 'DEVELOP', + subTrack: 'SRM', + track: 'DATA_SCIENCE', }) }) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts index 0f151a6d2..3b028d28b 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts @@ -341,7 +341,7 @@ const getAIEngineeringRatingCandidates = (memberStats?: UserStats): RatingCandid } /** - * Builds the fallback rating candidate from the historical maximum rating payload. + * Builds a rating candidate from the API maxRating payload. * * @param {UserStats | undefined} memberStats - Raw member stats for the profile user. * @returns {RatingCandidate | undefined} Max rating candidate when enough metadata is available. @@ -363,41 +363,48 @@ const getMaxRatingCandidate = (memberStats?: UserStats): RatingCandidate | undef } /** - * Returns the latest rated track candidate used by the compact rating card. + * Returns the highest rated track candidate used by the compact rating card. * * @param {UserStats | undefined} memberStats - Raw member stats for the profile user. - * @returns {RatingCandidate | undefined} Latest current rating candidate, or max rating fallback. + * @returns {RatingCandidate | undefined} Highest current rating candidate, with event date as a tie-breaker. */ -const getLatestProfileRatingCandidate = (memberStats?: UserStats): RatingCandidate | undefined => { +const getProfileRatingCandidate = (memberStats?: UserStats): RatingCandidate | undefined => { const candidates: RatingCandidate[] = [ + getMaxRatingCandidate(memberStats), ...getSubTrackRatingCandidates('DEVELOP', memberStats?.DEVELOP?.subTracks), ...getSubTrackRatingCandidates('DESIGN', memberStats?.DESIGN?.subTracks), ...getDataScienceRatingCandidates(memberStats?.DATA_SCIENCE), ...getAIEngineeringRatingCandidates(memberStats), - ] + ].filter((candidate: RatingCandidate | undefined): candidate is RatingCandidate => candidate !== undefined) - const latestCandidate: RatingCandidate | undefined = candidates.reduce(( - latest: RatingCandidate | undefined, + return candidates.reduce(( + highest: RatingCandidate | undefined, candidate: RatingCandidate, - ) => ( - latest === undefined || candidate.ratingDate > latest.ratingDate ? candidate : latest - ), undefined) + ) => { + if ( + highest === undefined + || candidate.rating > highest.rating + || (candidate.rating === highest.rating && candidate.ratingDate > highest.ratingDate) + ) { + return candidate + } - return latestCandidate ?? getMaxRatingCandidate(memberStats) + return highest + }, undefined) } /** * Returns the rating that should be shown on the compact profile rating card. * - * The card should show the latest current rating from the user's rated tracks, - * not the historical maximum rating. `maxRating` is used only as a fallback - * when the stats payload does not include any rated track entries. + * The card should show the highest current rating from the user's rated tracks. + * The API `maxRating` payload participates in selection so the compact card + * stays aligned with the stats response, while rating event date only breaks ties. * * @param {UserStats | undefined} memberStats - Raw member stats for the profile user. - * @returns {number | undefined} Latest current rating, or the max rating fallback when no track rating exists. + * @returns {number | undefined} Highest current rating when available. */ -export const getLatestProfileRating = (memberStats?: UserStats): number | undefined => ( - getLatestProfileRatingCandidate(memberStats)?.rating +export const getProfileRating = (memberStats?: UserStats): number | undefined => ( + getProfileRatingCandidate(memberStats)?.rating ) /** @@ -418,7 +425,7 @@ const isAIEngineeringRatingCandidate = (ratingCandidate: RatingCandidate): boole * @returns {RatingDistributionQuery | undefined} The API query for rating distribution data. */ export const getRatingDistributionQuery = (memberStats?: UserStats): RatingDistributionQuery | undefined => { - const ratingCandidate = getLatestProfileRatingCandidate(memberStats) + const ratingCandidate = getProfileRatingCandidate(memberStats) if (!ratingCandidate) { return undefined @@ -444,7 +451,7 @@ export const getRatingDistributionQuery = (memberStats?: UserStats): RatingDistr * @returns {string} The broad track audience label for the rating card and modal. */ export const getRatingAudienceLabel = (memberStats?: UserStats): string => { - const ratingCandidate = getLatestProfileRatingCandidate(memberStats) + const ratingCandidate = getProfileRatingCandidate(memberStats) const normalizedTrack = normalizeTrackToken(ratingCandidate?.track) const normalizedSubTrack = normalizeTrackToken(ratingCandidate?.subTrack) From 69024a326eebfbaa6577b8a8dc987473099e5b29 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 17 Jun 2026 18:17:36 +1000 Subject: [PATCH 43/73] PM-5221: show Data Science challenges as Code stats What was broken The previous platform-ui fix displayed member-api DATA_SCIENCE.Challenge ratings and history as a Data Science > Challenge subtrack. QA confirmed the backend now returns the history, but production profile behavior shows non-Marathon-Match Data Science challenges under Development > Code. Root cause (if identifiable) The profile active-track mapper treated DATA_SCIENCE.Challenge as a native Data Science display subtrack, so the achievements card and drill-down route disagreed with the existing production bucket for these challenge ratings. What was changed Remapped DATA_SCIENCE.Challenge into the Development Code subtrack while keeping explicit history and distribution lookup paths pointed at DATA_SCIENCE.Challenge. Data Science now uses Marathon Match for its summary again, and Code rows with zero submissions remain visible when they have challenge history. Any added/updated tests Updated getActiveTracks coverage to assert Data Science Challenge stats display under Development > Code, and added getTrackHistoryFromStats coverage for compatibility history paths. --- .../DevelopTrackView/DevelopTrackView.tsx | 6 +- .../src/hooks/useFetchActiveTracks.spec.tsx | 29 +++- .../src/hooks/useFetchActiveTracks.tsx | 157 ++++++++++++++---- .../src/hooks/useTrackHistory.spec.tsx | 49 ++++++ .../profiles/src/hooks/useTrackHistory.tsx | 90 ++++++++-- src/libs/core/lib/profile/user-stats.model.ts | 17 ++ 6 files changed, 292 insertions(+), 56 deletions(-) create mode 100644 src/apps/profiles/src/hooks/useTrackHistory.spec.tsx diff --git a/src/apps/profiles/src/components/tc-achievements/DevelopTrackView/DevelopTrackView.tsx b/src/apps/profiles/src/components/tc-achievements/DevelopTrackView/DevelopTrackView.tsx index 5b266e3ed..6769071cd 100644 --- a/src/apps/profiles/src/components/tc-achievements/DevelopTrackView/DevelopTrackView.tsx +++ b/src/apps/profiles/src/components/tc-achievements/DevelopTrackView/DevelopTrackView.tsx @@ -24,11 +24,13 @@ const DevelopTrackView: FC = props => { 'First2Finish', 'DESIGN_FIRST_2_FINISH', ].includes(trackName), [trackName]) + const statsDistributionTrack = props.trackData.statsDistributionTrack ?? props.trackData.parentTrack + const statsDistributionSubTrack = props.trackData.statsDistributionSubTrack ?? trackName const ratingDistribution: UserStatsDistributionResponse | undefined = useStatsDistribution( isDesignTrack ? undefined : { - subTrack: trackName, - track: props.trackData.parentTrack, + subTrack: statsDistributionSubTrack, + track: statsDistributionTrack, }, ) diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx index 7db0305f6..7f7dcd100 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx @@ -74,7 +74,7 @@ describe('getActiveTracks', () => { .toEqual(['Challenge', 'Task']) }) - it('includes Data Science Challenge stats in the Data Science track', () => { + it('shows Data Science Challenge stats under the Development Code subtrack', () => { const activeTracks: MemberStatsTrack[] = getActiveTracks({ DATA_SCIENCE: { Challenge: { @@ -96,15 +96,34 @@ describe('getActiveTracks', () => { } as UserStats) const dataScienceTrack: MemberStatsTrack | undefined = activeTracks .find(track => track.name === 'Data Science') + const developmentTrack: MemberStatsTrack | undefined = activeTracks + .find(track => track.name === 'Development') + const codeSubTrack = developmentTrack?.subTracks + .find(track => track.name === 'CODE') expect(dataScienceTrack?.challenges) - .toEqual(2) - expect(dataScienceTrack?.wins) .toEqual(1) expect(dataScienceTrack?.rating) - .toEqual(1499) + .toEqual(763) expect(dataScienceTrack?.subTracks.map(track => track.name)) - .toEqual(['Challenge', 'MARATHON_MATCH']) + .toEqual(['MARATHON_MATCH']) + expect(developmentTrack?.challenges) + .toEqual(1) + expect(developmentTrack?.wins) + .toEqual(1) + expect(developmentTrack?.subTracks.map(track => track.name)) + .toEqual(['CODE']) + expect(codeSubTrack) + .toEqual(expect.objectContaining({ + historyPaths: ['DATA_SCIENCE.Challenge.history'], + name: 'CODE', + parentTrack: 'DEVELOP', + path: 'DEVELOP.subTracks', + statsDistributionSubTrack: 'Challenge', + statsDistributionTrack: 'DATA_SCIENCE', + })) + expect(codeSubTrack?.rank?.rating) + .toEqual(1499) expect(activeTracks.map(track => track.name)) .not.toContain('Challenge') }) diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx index ea1950108..bb282eccd 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx @@ -29,6 +29,8 @@ const nativeDataScienceStatsKeys = new Set([ 'wins', ]) +const dataScienceChallengeCodeHistoryPath = 'DATA_SCIENCE.Challenge.history' + /** * The structure of a track for a member. */ @@ -66,9 +68,8 @@ export const getSubTrackSubmissionCount = (subTrack?: MemberStats): number | und /** * Determine whether the subtrack should be considered active. * - * Unified member stats do not currently include legacy submission counters for - * development/design rows, so fall back to the challenge count when the - * submission count is unavailable. + * Some rated Code rows can have zero submissions while still having challenge + * history, so challenge count also keeps the subtrack visible. * * @param {MemberStats | undefined} subTrack - The subtrack to inspect. * @returns {boolean} Whether the subtrack has activity worth rendering. @@ -76,7 +77,7 @@ export const getSubTrackSubmissionCount = (subTrack?: MemberStats): number | und const isActiveSubTrack = (subTrack?: MemberStats): boolean => { const submissionCount = getSubTrackSubmissionCount(subTrack) - return (submissionCount ?? subTrack?.challenges ?? 0) > 0 + return (submissionCount ?? 0) > 0 || (subTrack?.challenges ?? 0) > 0 } /** @@ -94,24 +95,30 @@ const isTestingSubTrack = (subTrack?: MemberStats): boolean => ( ) /** - * Pick the Data Science subtrack rating used by the summary card. + * Convert a Data Science Challenge stats payload into the legacy Code subtrack + * shown under Development. * - * Data Science can include Marathon Match and challenge ratings. The summary - * should show the strongest visible rating instead of always using Marathon - * Match, otherwise Data Science Challenge ratings are hidden from the profile. + * Member-api stores newly rated Data Science Challenge rows under + * `DATA_SCIENCE.Challenge`, but the profile history UI presents non-MM data + * science challenges in Development > Code for parity with existing profiles. * - * @param {MemberStats[]} subTracks - Active Data Science subtracks. - * @returns {MemberStats | undefined} The subtrack with the highest rating. + * @param {UserStats | undefined} memberStats - The raw stats payload for the user. + * @returns {MemberStats | undefined} Display-ready Code stats when available. */ -const getDataScienceSummarySubTrack = (subTracks: MemberStats[]): MemberStats | undefined => orderBy( - subTracks, - [ - subTrack => subTrack.rank?.rating ?? 0, - subTrack => subTrack.rank?.percentile ?? 0, - subTrack => subTrack.challenges ?? 0, - ], - ['desc', 'desc', 'desc'], -)[0] +const buildDataScienceChallengeCodeSubTrack = (memberStats?: UserStats): MemberStats | undefined => { + const challengeStats = memberStats?.DATA_SCIENCE?.Challenge + + return !challengeStats ? undefined : ({ + ...challengeStats, + historyPaths: [dataScienceChallengeCodeHistoryPath], + name: 'CODE', + parentTrack: 'DEVELOP', + path: 'DEVELOP.subTracks', + statsDistributionSubTrack: 'Challenge', + statsDistributionTrack: 'DATA_SCIENCE', + submissions: challengeStats.submissions ?? { submissions: 0 }, + } as MemberStats) +} /** * Attach parent track metadata to legacy design/develop subtracks and index them by name. @@ -139,6 +146,96 @@ const getFiniteNumber = (value: unknown): number | undefined => ( typeof value === 'number' && Number.isFinite(value) ? value : undefined ) +/** + * Return the rank object that should represent a merged subtrack. + * + * When both streams have ratings, the visible Code stats should keep the + * stronger rating so a Data Science Challenge rating is not hidden by a lower + * legacy Development Code rating. + * + * @param {MemberStats} currentSubTrack - Existing displayed subtrack stats. + * @param {MemberStats} additionalSubTrack - Additional stats to merge. + * @returns {MemberStats['rank']} Rank data for the merged subtrack. + */ +const getMergedSubTrackRank = ( + currentSubTrack: MemberStats, + additionalSubTrack: MemberStats, +): MemberStats['rank'] => { + const currentRating = getFiniteNumber(currentSubTrack.rank?.rating) + const additionalRating = getFiniteNumber(additionalSubTrack.rank?.rating) + + if (currentRating === undefined) { + return additionalSubTrack.rank ?? currentSubTrack.rank + } + + if (additionalRating === undefined) { + return currentSubTrack.rank + } + + return additionalRating > currentRating ? additionalSubTrack.rank : currentSubTrack.rank +} + +/** + * Merge two displayed subtracks with the same name. + * + * This keeps Development > Code as one profile bucket when a member has both + * legacy Code stats and Data Science Challenge stats backed by different API + * dimensions. + * + * @param {MemberStats} currentSubTrack - Existing displayed subtrack stats. + * @param {MemberStats} additionalSubTrack - Additional stats to merge. + * @returns {MemberStats} Merged stats for the displayed subtrack. + */ +const mergeDisplayedSubTrackStats = ( + currentSubTrack: MemberStats, + additionalSubTrack: MemberStats, +): MemberStats => { + const currentSubmissions = getSubTrackSubmissionCount(currentSubTrack) + const additionalSubmissions = getSubTrackSubmissionCount(additionalSubTrack) + const hasSubmissionCounts = currentSubmissions !== undefined || additionalSubmissions !== undefined + + return { + ...currentSubTrack, + challenges: (currentSubTrack.challenges ?? 0) + (additionalSubTrack.challenges ?? 0), + historyPaths: Array.from(new Set([ + ...(currentSubTrack.historyPaths ?? []), + ...(additionalSubTrack.historyPaths ?? []), + ])), + rank: getMergedSubTrackRank(currentSubTrack, additionalSubTrack), + submissions: hasSubmissionCounts + ? { submissions: (currentSubmissions ?? 0) + (additionalSubmissions ?? 0) } + : currentSubTrack.submissions, + wins: (currentSubTrack.wins ?? 0) + (additionalSubTrack.wins ?? 0), + } +} + +/** + * Adds Data Science Challenge stats to the Development > Code bucket. + * + * @param {{[key: string]: MemberStats}} developSubTracks - Existing Development subtracks. + * @param {UserStats | undefined} memberStats - The raw stats payload for the user. + * @returns {{[key: string]: MemberStats}} Development subtracks with the Code compatibility mapping. + */ +const addDataScienceChallengeToDevelopmentCode = ( + developSubTracks: {[key: string]: MemberStats}, + memberStats?: UserStats, +): {[key: string]: MemberStats} => { + const dataScienceChallengeCodeSubTrack = buildDataScienceChallengeCodeSubTrack(memberStats) + + if (!dataScienceChallengeCodeSubTrack) { + return developSubTracks + } + + const codeSubTrack = developSubTracks[dataScienceChallengeCodeSubTrack.name] + + return { + ...developSubTracks, + [dataScienceChallengeCodeSubTrack.name]: codeSubTrack + ? mergeDisplayedSubTrackStats(codeSubTrack, dataScienceChallengeCodeSubTrack) + : dataScienceChallengeCodeSubTrack, + } +} + /** * Determine whether a DATA_SCIENCE entry is a configured rating path. * @@ -355,13 +452,6 @@ const getDataScienceRatingPathTrackData = (memberStats?: UserStats): MemberStats export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => { // Create mappings for data science subtracks const dataScienceSubTracks: {[key: string]: MemberStats | SRMStats} = { - // Map Challenge subtrack - Challenge: (memberStats?.DATA_SCIENCE?.Challenge && ({ - ...memberStats.DATA_SCIENCE.Challenge, - name: 'Challenge', - parentTrack: 'DATA_SCIENCE', - path: 'DATA_SCIENCE', - })) as MemberStats, // Map MARATHON_MATCH subtrack MARATHON_MATCH: (memberStats?.DATA_SCIENCE?.MARATHON_MATCH && ({ ...memberStats.DATA_SCIENCE.MARATHON_MATCH, @@ -386,9 +476,12 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => ) // Create mappings for develop subtracks - const developSubTracks: {[key: string]: MemberStats} = mapSubTracksByName( - 'DEVELOP', - memberStats?.DEVELOP?.subTracks, + const developSubTracks: {[key: string]: MemberStats} = addDataScienceChallengeToDevelopmentCode( + mapSubTracksByName( + 'DEVELOP', + memberStats?.DEVELOP?.subTracks, + ), + memberStats, ) // Build aggregated stats for Design, Development, Testing, and Competitive Programming tracks @@ -422,19 +515,17 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => // Data science const dsSubTracks: MemberStats[] = [ - dataScienceSubTracks.Challenge, dataScienceSubTracks.MARATHON_MATCH, ].filter(d => d?.challenges > 0) as MemberStats[] const dsTrackData: MemberStatsTrack = buildTrackData('Data Science', dsSubTracks) - const dsSummarySubTrack: MemberStats | undefined = getDataScienceSummarySubTrack(dsTrackData.subTracks) const dataScienceRatingPathTrackStats: MemberStatsTrack[] = getDataScienceRatingPathTrackData(memberStats) const dsTrackStats: MemberStatsTrack = { ...dsTrackData, isDSTrack: true, order: -1, - percentile: dsSummarySubTrack?.rank?.percentile ?? 0, - rating: dsSummarySubTrack?.rank?.rating ?? 0, + percentile: dataScienceSubTracks.MARATHON_MATCH?.rank?.percentile ?? 0, + rating: dataScienceSubTracks.MARATHON_MATCH?.rank?.rating ?? 0, } // Competitive Programming diff --git a/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx b/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx new file mode 100644 index 000000000..dba34db1f --- /dev/null +++ b/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx @@ -0,0 +1,49 @@ +import type { MemberStats, UserStatsHistory } from '~/libs/core' + +import { getTrackHistoryFromStats } from './useTrackHistory' + +jest.mock('~/libs/core', () => ({ + useStatsHistory: jest.fn(), +}), { + virtual: true, +}) + +describe('getTrackHistoryFromStats', () => { + it('reads compatibility history paths for displayed subtracks', () => { + const trackData = { + historyPaths: ['DATA_SCIENCE.Challenge.history'], + name: 'CODE', + parentTrack: 'DEVELOP', + path: 'DEVELOP.subTracks', + } as MemberStats + const history = getTrackHistoryFromStats({ + DATA_SCIENCE: { + Challenge: { + history: [ + { + challengeId: 'ds-challenge', + challengeName: 'Data Science Challenge', + newRating: 1499, + ratingDate: 1781237773026, + }, + ], + }, + }, + DEVELOP: { + subTracks: [], + }, + groupId: 10, + handle: 'testcoun', + handleLower: 'testcoun', + userId: 89770325, + } as UserStatsHistory, trackData) + + expect(history) + .toEqual([ + expect.objectContaining({ + challengeId: 'ds-challenge', + newRating: 1499, + }), + ]) + }) +}) diff --git a/src/apps/profiles/src/hooks/useTrackHistory.tsx b/src/apps/profiles/src/hooks/useTrackHistory.tsx index c20462fee..80a5e6cfe 100644 --- a/src/apps/profiles/src/hooks/useTrackHistory.tsx +++ b/src/apps/profiles/src/hooks/useTrackHistory.tsx @@ -1,29 +1,87 @@ -import { find, get } from 'lodash' +import { find, get, orderBy, uniqBy } from 'lodash' import { MemberStats, StatsHistory, UserStatsHistory, useStatsHistory } from '~/libs/core' /** - * Fetches the user stats history and extracts the history data for the specified track - * @param userHandle - User's handle - * @param trackData - The track data for which we want to fetch the history - * @returns + * Extracts the history array from an exact stats-history path. + * + * @param {UserStatsHistory | undefined} statsHistory - Raw stats-history payload. + * @param {string} historyPath - Exact lodash path to a history array. + * @returns {StatsHistory[]} History rows at the supplied path. */ -export const useTrackHistory = (userHandle?: string, trackData?: MemberStats): StatsHistory[] => { - const statsHistory: UserStatsHistory | undefined = useStatsHistory(userHandle) +const getHistoryFromExactPath = ( + statsHistory: UserStatsHistory | undefined, + historyPath: string, +): StatsHistory[] => get(statsHistory, historyPath, []) + +/** + * Fetches the default history data for a track using its API path and name. + * + * @param {UserStatsHistory | undefined} statsHistory - Raw stats-history payload. + * @param {MemberStats} trackData - The track data for which to fetch history. + * @returns {StatsHistory[]} History rows for the displayed track. + */ +const getDefaultTrackHistory = ( + statsHistory: UserStatsHistory | undefined, + trackData: MemberStats, +): StatsHistory[] => get( + find(get(statsHistory, `${trackData.path}`, []), { name: trackData.name }), + 'history', +) + // marathon match and some unified stats dimensions have a keyed history structure + || get( + statsHistory, + `${trackData.path}.${trackData.name}.history`, + ) || [] +/** + * Extracts the history rows for a displayed track. + * + * Some displayed tracks merge history from compatibility paths, such as + * Development > Code reading Data Science Challenge history from the API. + * + * @param {UserStatsHistory | undefined} statsHistory - Raw stats-history payload. + * @param {MemberStats | undefined} trackData - The track data for which to fetch history. + * @returns {StatsHistory[]} History rows for the displayed track. + */ +export const getTrackHistoryFromStats = ( + statsHistory: UserStatsHistory | undefined, + trackData?: MemberStats, +): StatsHistory[] => { if (!trackData) { return [] } - const trackHistory: StatsHistory[] = get( - find(get(statsHistory, `${trackData.path}`, []), { name: trackData.name }), - 'history', + const trackHistory = getDefaultTrackHistory(statsHistory, trackData) + const compatibilityHistory = (trackData.historyPaths ?? []) + .flatMap(historyPath => getHistoryFromExactPath(statsHistory, historyPath)) + + if (compatibilityHistory.length === 0) { + return trackHistory + } + + return orderBy( + uniqBy( + [ + ...trackHistory, + ...compatibilityHistory, + ], + history => String(history.challengeId), + ), + [history => history.ratingDate ?? history.date ?? 0], + ['desc'], ) - // marathon match has a different structure for the stats history - || get( - statsHistory, - `${trackData.path}.${trackData.name}.history`, - ) || [] +} + +/** + * Fetches the user stats history and extracts the history data for the specified track. + * + * @param {string | undefined} userHandle - User's handle. + * @param {MemberStats | undefined} trackData - The track data for which to fetch history. + * @returns {StatsHistory[]} History rows for the specified track. + */ +export const useTrackHistory = (userHandle?: string, trackData?: MemberStats): StatsHistory[] => { + const statsHistory: UserStatsHistory | undefined = useStatsHistory(userHandle) - return trackHistory + return getTrackHistoryFromStats(statsHistory, trackData) } diff --git a/src/libs/core/lib/profile/user-stats.model.ts b/src/libs/core/lib/profile/user-stats.model.ts index d251fc80e..5f2d15ba3 100644 --- a/src/libs/core/lib/profile/user-stats.model.ts +++ b/src/libs/core/lib/profile/user-stats.model.ts @@ -55,6 +55,23 @@ export type MemberStats = { } parentTrack?: string path?: string + /** + * Exact stats-history paths to merge into this displayed subtrack. + * + * Used when a legacy display bucket is backed by stats stored under a newer + * API dimension. + */ + historyPaths?: string[] + /** + * Track key used for rating-distribution lookups when it differs from the + * displayed parent track. + */ + statsDistributionTrack?: string + /** + * Subtrack key used for rating-distribution lookups when it differs from + * the displayed subtrack name. + */ + statsDistributionSubTrack?: string } /** From db472c1e8d06d315944690fbf7554b37d0be0ae4 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 17 Jun 2026 18:30:58 +1000 Subject: [PATCH 44/73] PM-5374: Fix blank Filestack env fallback What was broken Production markdown uploads could pass an invalid Filestack store config when a string env override, such as REACT_APP_FILESTACK_REGION, was present but blank. That caused MM example solution uploads to fail before the uploaded file link was inserted into the specification editor. Root cause (if identifiable) getReactEnv converted blank strings with Number("") before applying defaults, so blank env values became numeric 0 instead of falling back to defaults like us-east-1. What was changed Blank env values now use the provided default when one exists, while non-blank numeric and boolean env values keep the existing parsing behavior. Any added/updated tests Added src/config/environments/react-env.spec.ts to cover blank default fallback and existing boolean/numeric parsing. --- src/config/environments/react-env.spec.ts | 33 +++++++++++++++++++++++ src/config/environments/react-env.ts | 12 ++++++--- 2 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 src/config/environments/react-env.spec.ts diff --git a/src/config/environments/react-env.spec.ts b/src/config/environments/react-env.spec.ts new file mode 100644 index 000000000..fcf4562cc --- /dev/null +++ b/src/config/environments/react-env.spec.ts @@ -0,0 +1,33 @@ +import { getReactEnv } from './react-env' + +const originalEnv = process.env + +describe('getReactEnv', () => { + beforeEach(() => { + process.env = { ...originalEnv } + delete process.env.REACT_APP_FILESTACK_REGION + delete process.env.REACT_APP_FEATURE_FLAG + delete process.env.REACT_APP_RETRY_COUNT + }) + + afterAll(() => { + process.env = originalEnv + }) + + it('uses the provided default when an env value is blank', () => { + process.env.REACT_APP_FILESTACK_REGION = '' + + expect(getReactEnv('FILESTACK_REGION', 'us-east-1')) + .toBe('us-east-1') + }) + + it('keeps parsing boolean and numeric env values', () => { + process.env.REACT_APP_FEATURE_FLAG = 'true' + process.env.REACT_APP_RETRY_COUNT = '2' + + expect(getReactEnv('FEATURE_FLAG', false)) + .toBe(true) + expect(getReactEnv('RETRY_COUNT', 0)) + .toBe(2) + }) +}) diff --git a/src/config/environments/react-env.ts b/src/config/environments/react-env.ts index 1ed71d508..d46207e73 100644 --- a/src/config/environments/react-env.ts +++ b/src/config/environments/react-env.ts @@ -4,14 +4,20 @@ export function getReactEnv(varName: string, defaultValue?: string | boolean | number): T { const hasDefaultValue: boolean = arguments.length > 1 - let value = process.env[`REACT_APP_${varName}`] as unknown as T + const rawValue = process.env[`REACT_APP_${varName}`] - if (value === undefined && !hasDefaultValue) { + if (rawValue === undefined && !hasDefaultValue) { throw new Error(`${varName} is not defined in process.env!`) } + if (rawValue?.trim() === '' && hasDefaultValue) { + return defaultValue as T + } + + let value = rawValue as unknown as T + // convert to number - if (!Number.isNaN(Number(value))) { + if (rawValue?.trim() !== '' && !Number.isNaN(Number(value))) { value = Number(value as unknown) as T } From 7f7b186a342b84c8dd637aede34c42e5239a8a8e Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 17 Jun 2026 19:00:09 +1000 Subject: [PATCH 45/73] PM-5231: Show example validation scoring in submissions What was broken The Marathon Match validation upload flow could queue and score the Example phase, but the Work Manager Submissions tab did not show Example as the current test process or display its validation score. Root cause (if identifiable) The Work submissions table only normalized Provisional and System marathon test processes for progress display, and marathon score display only read Provisional and System summations. Example summations from the scorer callback were intentionally ignored by those helpers, leaving validation-only Example rows blank. What was changed Added explicit Example process normalization and an Example score helper for marathon review summations. The submissions table now renders human-readable marathon process labels and uses the Example score when Example is the current validation process, while preserving existing Provisional/System score handling. Any added/updated tests Added utility coverage for Example progress and score extraction, plus table coverage for rendering the Example process, status, progress, and score. --- .../SubmissionsTable.spec.tsx | 55 ++++++++++++++- .../SubmissionsTable/SubmissionsTable.tsx | 55 +++++++++++++-- .../src/lib/utils/challenge.utils.spec.ts | 67 +++++++++++++++++++ .../work/src/lib/utils/challenge.utils.ts | 61 ++++++++++++++--- 4 files changed, 221 insertions(+), 17 deletions(-) diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx index d5122303b..5d383fceb 100644 --- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx +++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx @@ -22,6 +22,13 @@ jest.mock('../../constants', () => ({ jest.mock('../../utils', () => ({ formatDateTime: (value: string) => value, getRatingLevel: () => 'gray', + getSubmissionExampleScore: ( + submission: { reviewSummation?: Array<{ aggregateScore?: number, isExample?: boolean }> }, + ) => ( + submission.reviewSummation + ?.find(item => item.isExample === true) + ?.aggregateScore + ), getSubmissionFinalScore: (submission: { review?: Array<{ finalScore?: number }> }) => ( submission.review?.[0]?.finalScore ?? 0 ), @@ -46,9 +53,10 @@ jest.mock('../../utils', () => ({ submission: { reviewSummation?: Array<{ metadata?: { - testProcess?: 'provisional' | 'system' + testProcess?: 'example' | 'provisional' | 'system' testProgress?: number testStatus?: 'FAILED' | 'IN PROGRESS' | 'SUCCESS' + testType?: 'example' | 'provisional' | 'system' } }> }, @@ -57,7 +65,7 @@ jest.mock('../../utils', () => ({ const progress = metadata?.testProgress return { - process: metadata?.testProcess, + process: metadata?.testProcess ?? metadata?.testType, progressPercent: typeof progress === 'number' ? `${Math.round(progress * 100)}%` : undefined, @@ -390,4 +398,47 @@ describe('SubmissionsTable', () => { expect(screen.queryByRole('link', { name: '80.00 / 85.00' })) .toBeNull() }) + + it('renders example validation process and score for marathon submissions', () => { + render( + , + ) + + expect(screen.getByRole('link', { name: '15.25 / -' })) + .toBeTruthy() + expect(screen.getByText('Example')) + .toBeTruthy() + expect(screen.getByText('100%')) + .toBeTruthy() + expect(screen.getByRole('img', { name: 'Test status: SUCCESS' })) + .toBeTruthy() + }) }) diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx index bda7e5928..955a527fa 100644 --- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx +++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx @@ -19,6 +19,7 @@ import { Submission } from '../../models' import { formatDateTime, getRatingLevel, + getSubmissionExampleScore, getSubmissionFinalScore, getSubmissionInitialScore, getSubmissionProvisionalScore, @@ -141,6 +142,50 @@ function formatScore(value?: number, emptyValue: string = 'N/A'): string { return value.toFixed(2) } +/** + * Converts normalized marathon test process metadata into a table display label. + * @param process Current marathon test process from review summation metadata. + * @returns Human-readable process label, or an empty string when absent. + * Used by `SubmissionsTable` for the Current tests process column. + */ +function formatTestProcess(process?: string): string { + if (process === 'example') { + return 'Example' + } + + if (process === 'provisional') { + return 'Provisional' + } + + if (process === 'system') { + return 'System' + } + + return '' +} + +/** + * Selects the marathon score shown in the left side of the score column. + * @param submission Submission row with review summation data. + * @param process Current marathon test process from progress metadata. + * @returns Example score when Example is current; otherwise the provisional score. + * Used by `SubmissionsTable` for marathon score display. + */ +function getMarathonInitialScore( + submission: Submission, + process?: string, +): number | undefined { + if (process === 'example') { + const exampleScore = getSubmissionExampleScore(submission) + + if (exampleScore !== undefined) { + return exampleScore + } + } + + return getSubmissionProvisionalScore(submission) +} + function getSortIndicator( fieldName: SubmissionSortBy | undefined, currentSortBy: SubmissionSortBy, @@ -327,8 +372,11 @@ export const SubmissionsTable: FC = ( const handleDisplay = getHandleDisplay(submission, !!props.isLoadingMembers) const emailDisplay = getEmailDisplay(submission, !!props.isLoadingMembers) const submissionDate = formatDateTime(getCreatedAt(submission)) + const testProgress = props.showMarathonMatchTestProgress + ? getSubmissionTestProgress(submission) + : undefined const initialScoreValue = props.showMarathonMatchTestProgress - ? getSubmissionProvisionalScore(submission) + ? getMarathonInitialScore(submission, testProgress?.process) : getSubmissionInitialScore(submission) const finalScoreValue = props.showMarathonMatchTestProgress ? getSubmissionSystemScore(submission) @@ -338,9 +386,6 @@ export const SubmissionsTable: FC = ( : 'N/A' const initialScore = formatScore(initialScoreValue, emptyScoreValue) const finalScore = formatScore(finalScoreValue, emptyScoreValue) - const testProgress = props.showMarathonMatchTestProgress - ? getSubmissionTestProgress(submission) - : undefined const reviewTab = submission.type === 'CHECKPOINT_SUBMISSION' ? 'checkpoint-submission' : 'submission' @@ -396,7 +441,7 @@ export const SubmissionsTable: FC = ( ? ( <> - {testProgress?.process || ''} + {formatTestProcess(testProgress?.process)} diff --git a/src/apps/work/src/lib/utils/challenge.utils.spec.ts b/src/apps/work/src/lib/utils/challenge.utils.spec.ts index 142b50072..70fb4a35f 100644 --- a/src/apps/work/src/lib/utils/challenge.utils.spec.ts +++ b/src/apps/work/src/lib/utils/challenge.utils.spec.ts @@ -1,6 +1,8 @@ import { + getSubmissionExampleScore, getSubmissionProvisionalScore, getSubmissionSystemScore, + getSubmissionTestProgress, isMarathonMatchChallenge, } from './challenge.utils' @@ -34,6 +36,71 @@ describe('challenge utils', () => { }) }) + describe('getSubmissionExampleScore', () => { + it('returns only example marathon scores', () => { + expect(getSubmissionExampleScore({ + reviewSummation: [ + { + aggregateScore: 10, + isExample: true, + metadata: { + testType: 'example', + }, + }, + { + aggregateScore: 12, + isProvisional: true, + metadata: { + testProcess: 'provisional', + }, + }, + ], + })) + .toBe(10) + }) + + it('returns the latest example summation', () => { + expect(getSubmissionExampleScore({ + reviewSummation: [ + { + aggregateScore: 10, + isExample: true, + updatedAt: '2026-06-17T01:00:00.000Z', + }, + { + aggregateScore: 15.25, + isExample: true, + updatedAt: '2026-06-17T02:00:00.000Z', + }, + ], + })) + .toBe(15.25) + }) + }) + + describe('getSubmissionTestProgress', () => { + it('returns example progress from marathon metadata test type', () => { + expect(getSubmissionTestProgress({ + reviewSummation: [ + { + aggregateScore: 10, + isExample: true, + metadata: { + testProgress: 1, + testStatus: 'SUCCESS', + testType: 'example', + }, + }, + ], + })) + .toEqual({ + process: 'example', + progressPercent: '100%', + status: 'SUCCESS', + }) + }) + }) + describe('getSubmissionProvisionalScore', () => { it('returns only provisional marathon scores', () => { expect(getSubmissionProvisionalScore({ diff --git a/src/apps/work/src/lib/utils/challenge.utils.ts b/src/apps/work/src/lib/utils/challenge.utils.ts index 69dad2905..c346aac8e 100644 --- a/src/apps/work/src/lib/utils/challenge.utils.ts +++ b/src/apps/work/src/lib/utils/challenge.utils.ts @@ -14,6 +14,8 @@ interface SubmissionScore { provisionalScore?: number } +type MarathonMatchScoreProcess = 'example' | 'provisional' | 'system' + interface ScoredSubmissionLike { review?: Array<{ initialScore?: number @@ -30,7 +32,7 @@ interface ScoredSubmissionLike { * Display metadata for marathon test progress columns. */ export interface SubmissionTestProgressDisplay { - process?: 'provisional' | 'system' + process?: MarathonMatchScoreProcess progressPercent?: string status?: 'FAILED' | 'IN PROGRESS' | 'SUCCESS' } @@ -169,12 +171,12 @@ function getScoreFromSummation( /** * Resolves the marathon scoring phase represented by a review summation. * @param entry Review summation returned by Review API. - * @returns `provisional`, `system`, or `undefined` when the summation is not a scored marathon phase. + * @returns `example`, `provisional`, `system`, or `undefined` when the summation is not a scored marathon phase. * Used by strict marathon score helpers so progress/example reviews do not leak into score display. */ function getReviewSummationTestProcess( entry: ReviewSummation, -): 'provisional' | 'system' | undefined { +): MarathonMatchScoreProcess | undefined { const metadataProcess = normalizeTestProcess( entry.metadata?.testProcess ?? entry.metadata?.testType, @@ -193,6 +195,10 @@ function getReviewSummationTestProcess( return 'system' } + if (entry.isExample === true) { + return 'example' + } + if (entry.isFinal === true) { return 'system' } @@ -201,7 +207,7 @@ function getReviewSummationTestProcess( return 'provisional' } - if (entry.isFinal === false && entry.isExample !== true) { + if (entry.isFinal === false) { return 'provisional' } @@ -223,15 +229,19 @@ function getChallengeTypeName(type: string | ChallengeTypeRef | undefined): stri /** * Normalizes review summation metadata test process aliases for marathon display. * @param value Metadata process or legacy test type value from Review API. - * @returns `provisional` or `system` when the value identifies a tracked process. - * Used by `getSubmissionTestProgress` to avoid showing example-test metadata. + * @returns `example`, `provisional`, or `system` when the value identifies a tracked process. + * Used by `getSubmissionTestProgress` to display the active marathon validation phase. */ -function normalizeTestProcess(value: unknown): 'provisional' | 'system' | undefined { +function normalizeTestProcess(value: unknown): MarathonMatchScoreProcess | undefined { const normalized = typeof value === 'string' ? value.trim() .toLowerCase() : '' + if (normalized === 'example') { + return 'example' + } + if (normalized === 'system' || normalized === 'final') { return 'system' } @@ -280,6 +290,24 @@ function normalizeTestProgress(value: unknown): number | undefined { return Math.min(Math.max(progress, 0), 1) } +/** + * Assigns a stable ordering weight for marathon test processes. + * @param process Normalized marathon test process. + * @returns Numeric priority used to break ties between progress records. + * Used by `toSubmissionTestProgressCandidate` after timestamp and status ordering. + */ +function getTestProcessPriority(process: MarathonMatchScoreProcess | undefined): number { + if (process === 'system') { + return 2 + } + + if (process === 'provisional') { + return 1 + } + + return 0 +} + /** * Resolves the best timestamp available for ordering test progress summations. * @param entry Review summation containing marathon metadata. @@ -330,9 +358,7 @@ function toSubmissionTestProgressCandidate( ? 1 : 0, process, - processPriority: process === 'system' - ? 1 - : 0, + processPriority: getTestProcessPriority(process), progress, progressPercent: progress === undefined ? undefined @@ -466,6 +492,21 @@ export function is2RoundsChallenge(challenge: Pick): boolea return !!challenge.phases?.find(phase => phase.name === 'Checkpoint Submission') } +/** + * Returns only the example marathon score for a submission. + * @param submission Submission-like object containing Review API summations. + * @returns Example score, or `undefined` when example scoring has not produced a valid score. + * Used by marathon submission table display when the current validation process is Example. + */ +export function getSubmissionExampleScore( + submission: ScoredSubmissionLike, +): number | undefined { + return getScoreFromSummation( + submission.reviewSummation, + item => getReviewSummationTestProcess(item) === 'example', + ) +} + export function getProvisionalScore(submission: ScoredSubmissionLike): number { return getSubmissionInitialScore(submission) } From 38d7ca768529fd152326ce105cb98abccfd72780 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 17 Jun 2026 19:11:08 +1000 Subject: [PATCH 46/73] PM-5384: Reduce profile photo username gap What was broken The member profile summary sat too far below the desktop profile picture, leaving an oversized gap before the "Hello, I'm ..." greeting shown in the left column. Root cause The desktop left profile column only moved up by 60px even though the profile photo overlaps farther into the white content area after the current header layout. What was changed Adjusted the desktop left profile column offset so the About Me greeting sits closer to the profile photo. The tablet and mobile stacked layout keeps its existing zero offset. Any added/updated tests No tests were added because this is a CSS-only spacing adjustment. Validation included lint, production build, a related-test lookup, and the existing profile-area test suite. --- .../member-profile/page-layout/ProfilePageLayout.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss b/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss index f6df932a7..8cfb8bd1c 100644 --- a/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss +++ b/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss @@ -118,7 +118,7 @@ .profileInfoLeft { display: flex; flex-direction: column; - margin-top: -60px; + margin-top: -140px; @include ltelg { margin-top: 0; From 12c255a85ff3cb4d13cebf3f7696f842204310ae Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 18 Jun 2026 08:48:26 +1000 Subject: [PATCH 47/73] Update for prod Filestack config' --- .environments/.env.prod | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.environments/.env.prod b/.environments/.env.prod index b4c21353f..1a460a189 100644 --- a/.environments/.env.prod +++ b/.environments/.env.prod @@ -14,6 +14,9 @@ REACT_APP_MEMBER_VERIFY_LOOKER=3322 REACT_APP_SPRIG_ENV_ID=a-IZBZ6-r7bU # Filestack configuration for uploading Submissions -REACT_APP_FILESTACK_API_KEY= -REACT_APP_FILESTACK_REGION= -REACT_APP_FILESTACK_SUBMISSION_CONTAINER= +REACT_APP_FILESTACK_API_KEY='AzFINuQoqTmqw0QEoaw9az' +REACT_APP_FILESTACK_REGION=us-east-1 +REACT_APP_FILESTACK_SUBMISSION_CONTAINER='topcoder-submissions-dmz' +REACT_APP_FILESTACK_UPLOAD_PROGRESS_INTERVAL=100 +REACT_APP_FILESTACK_UPLOAD_RETRY=2 +REACT_APP_FILESTACK_UPLOAD_TIMEOUT=1800000 From 129ef6c52680a1b10561f83d67ca8344eb170ee8 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 18 Jun 2026 09:34:24 +1000 Subject: [PATCH 48/73] Restrict challenge types to specific values, ignoring additional ones added for custom ratings --- .../constants/challenge-editor.constants.ts | 70 +++++++ .../challenges/ChallengeEditorPage/README.md | 8 +- .../ChallengeTypeField.utils.spec.ts | 143 ++++++++++++++- .../components/ChallengeTypeField.utils.ts | 173 +++++++++++++++--- src/config/environments/default.env.ts | 84 +++++++++ .../environments/global-config.model.ts | 3 + 6 files changed, 448 insertions(+), 33 deletions(-) diff --git a/src/apps/work/src/lib/constants/challenge-editor.constants.ts b/src/apps/work/src/lib/constants/challenge-editor.constants.ts index 03920da20..fbf7a6f80 100644 --- a/src/apps/work/src/lib/constants/challenge-editor.constants.ts +++ b/src/apps/work/src/lib/constants/challenge-editor.constants.ts @@ -5,6 +5,76 @@ export const SPECIAL_CHALLENGE_TAGS: string[] = [ 'Rapid Development Match', ] +export type CreateChallengeTypesByTrack = Record + +const DEFAULT_CREATE_CHALLENGE_TYPES_BY_TRACK: CreateChallengeTypesByTrack = { + DATA_SCIENCE: [ + 'Challenge', + 'First2Finish', + 'Marathon Match', + 'Task', + ], + DESIGN: [ + 'Challenge', + 'First2Finish', + 'Task', + ], + DEVELOP: [ + 'Challenge', + 'First2Finish', + 'Marathon Match', + 'Task', + ], + QA: [ + 'Challenge', + 'First2Finish', + 'Task', + ], +} + +/** + * Normalizes the configured create-challenge type allowlist for the work app. + * + * @param configValue environment config value from `WORK_CREATE_CHALLENGE_TYPES_BY_TRACK`. + * @returns configured track-to-type names when valid; otherwise the default work-app allowlist. + * @remarks Used by the create challenge type dropdown to keep hidden rating-specific types out of + * the launch flow while allowing deployment config to override the allowlist. + * @throws Does not throw. + */ +function parseCreateChallengeTypesByTrack( + configValue: unknown, +): CreateChallengeTypesByTrack { + if (!configValue || Array.isArray(configValue) || typeof configValue !== 'object') { + return DEFAULT_CREATE_CHALLENGE_TYPES_BY_TRACK + } + + const config = Object.entries(configValue) + .reduce((result, [track, challengeTypes]) => { + if (!Array.isArray(challengeTypes)) { + return result + } + + const normalizedChallengeTypes = challengeTypes + .filter((challengeType): challengeType is string => ( + typeof challengeType === 'string' && challengeType.trim().length > 0 + )) + .map(challengeType => challengeType.trim()) + + if (track.trim().length > 0 && normalizedChallengeTypes.length > 0) { + result[track.trim()] = normalizedChallengeTypes + } + + return result + }, {}) + + return Object.keys(config).length > 0 ? config : DEFAULT_CREATE_CHALLENGE_TYPES_BY_TRACK +} + +export const CREATE_CHALLENGE_TYPES_BY_TRACK = parseCreateChallengeTypesByTrack( + (EnvironmentConfig as unknown as Record) + .WORK_CREATE_CHALLENGE_TYPES_BY_TRACK, +) + export const SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS: string[] = ['80000062'] export const MAX_CHALLENGE_NAME_LENGTH = 200 diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 3d618de1b..793496027 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -59,7 +59,13 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `ChallengeNameField`: text input. - `ChallengeTrackField`: track selector from `useFetchChallengeTracks`. -- `ChallengeTypeField`: active type selector from `useFetchChallengeTypes`, excluding `Topgear Task` because that flow is not launchable from the work app editor. When the selected track is Design or QA, it also hides `Marathon Match` to match the legacy work-manager create flow and clears any now-invalid preselection. +- `ChallengeTypeField`: active type selector from `useFetchChallengeTypes`, limited by the + `WORK_CREATE_CHALLENGE_TYPES_BY_TRACK` config. The default work-app allowlist is Design: + `Challenge`, `First2Finish`, `Task`; Development and Data Science: `Challenge`, `First2Finish`, + `Marathon Match`, `Task`; QA: `Challenge`, `First2Finish`, `Task`. Topgear Task, AI, AI + Engineering, and other active API-only/internal challenge types stay hidden from the create + dropdown, and any now-invalid preselection is cleared when the track changes. Deployments can + override the allowlist with `REACT_APP_WORK_CREATE_CHALLENGE_TYPES_BY_TRACK` JSON. - `ChallengeScheduleSection`: schedule editor for challenge start and phase dates. It keeps the detected timezone above the controls, renders the `Start Date` label with the `Scheduled` and `Immediately` start-mode radios aligned to the end of that header row above the input with a green selected state, persists the selected start mode in challenge metadata so saved `/edit` and `/view` routes reopen with the correct radio state, recalculates root phase dates when the challenge start changes, honors a completed predecessor phase's actual end date when deriving successor schedule rows, lets active Design phases be shortened no earlier than the current date/time, prevents active non-Design phases from being shortened, and keeps completed phases' end-date and duration controls locked to match legacy work-manager behavior. `Task` challenges hide this editable section across create, edit, and read-only view routes to match legacy work-manager behavior. - `DesignWorkTypeField`: shown for Design + Challenge, with the legacy work-type options (`Application Front-End Design`, `Print/Presentation`, `Web Design`, `Widget or Mobile Screen Design`, `Wireframes`). The selected value is stored in challenge tags. - `FunChallengeField`: shown for `Marathon Match` type and remains editable after creation so the form can switch between fun-challenge and standard marathon-match fields. diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.spec.ts b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.spec.ts index 86ee57fe4..a8b6d6eed 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.spec.ts +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.spec.ts @@ -89,7 +89,16 @@ describe('getChallengeTypeFilterTrack', () => { name: 'Quality Assurance', track: 'QA', }))) - .toBe('QUALITY_ASSURANCE') + .toBe('QA') + }) + + it('normalizes development aliases', () => { + expect(getChallengeTypeFilterTrack(buildTrack({ + abbreviation: 'DEV', + name: 'Development', + track: 'Development', + }))) + .toBe('DEVELOP') }) it('returns an empty string when no track is selected', () => { @@ -99,7 +108,7 @@ describe('getChallengeTypeFilterTrack', () => { }) describe('buildChallengeTypeOptions', () => { - it('keeps only active launchable challenge types and sorts them by label', () => { + it('keeps only active configured challenge types and sorts them by label', () => { const options = buildChallengeTypeOptions([ buildChallengeType({ abbreviation: 'TGT', @@ -116,6 +125,21 @@ describe('buildChallengeTypeOptions', () => { id: 'challenge-id', name: 'Challenge', }), + buildChallengeType({ + abbreviation: 'AI', + id: 'ai-id', + name: 'AI', + }), + buildChallengeType({ + abbreviation: 'AIE', + id: 'ai-engineering-id', + name: 'AI Engineering', + }), + buildChallengeType({ + abbreviation: 'TEST', + id: 'test-id', + name: 'test11775112200655', + }), buildChallengeType({ abbreviation: 'MM', id: 'inactive-marathon-match-id', @@ -149,11 +173,26 @@ describe('buildChallengeTypeOptions', () => { id: 'marathon-match-id', name: 'Marathon Match', }), + buildChallengeType({ + abbreviation: 'F2F', + id: 'first-2-finish-id', + name: 'First2Finish', + }), buildChallengeType({ abbreviation: 'TSK', id: 'task-id', name: 'Task', }), + buildChallengeType({ + abbreviation: 'AI', + id: 'ai-id', + name: 'AI', + }), + buildChallengeType({ + abbreviation: 'AIE', + id: 'ai-engineering-id', + name: 'AI Engineering', + }), ], buildTrack({ name: 'Design', track: 'DESIGN', @@ -165,6 +204,10 @@ describe('buildChallengeTypeOptions', () => { label: 'Challenge', value: 'challenge-id', }, + { + label: 'First2Finish', + value: 'first-2-finish-id', + }, { label: 'Task', value: 'task-id', @@ -184,6 +227,16 @@ describe('buildChallengeTypeOptions', () => { id: 'marathon-match-id', name: 'Marathon Match', }), + buildChallengeType({ + abbreviation: 'F2F', + id: 'first-2-finish-id', + name: 'First2Finish', + }), + buildChallengeType({ + abbreviation: 'TSK', + id: 'task-id', + name: 'Task', + }), ], buildTrack({ abbreviation: 'QA', name: 'Quality Assurance', @@ -196,6 +249,92 @@ describe('buildChallengeTypeOptions', () => { label: 'Challenge', value: 'challenge-id', }, + { + label: 'First2Finish', + value: 'first-2-finish-id', + }, + { + label: 'Task', + value: 'task-id', + }, + ]) + }) + + it('shows Marathon Match for Development tracks', () => { + const options = buildChallengeTypeOptions([ + buildChallengeType({ + abbreviation: 'CH', + id: 'challenge-id', + name: 'Challenge', + }), + buildChallengeType({ + abbreviation: 'F2F', + id: 'first-2-finish-id', + name: 'First2Finish', + }), + buildChallengeType({ + abbreviation: 'MM', + id: 'marathon-match-id', + name: 'Marathon Match', + }), + buildChallengeType({ + abbreviation: 'TSK', + id: 'task-id', + name: 'Task', + }), + ], buildTrack({ + name: 'Development', + track: 'Development', + })) + + expect(options) + .toEqual([ + { + label: 'Challenge', + value: 'challenge-id', + }, + { + label: 'First2Finish', + value: 'first-2-finish-id', + }, + { + label: 'Marathon Match', + value: 'marathon-match-id', + }, + { + label: 'Task', + value: 'task-id', + }, + ]) + }) + + it('shows Marathon Match for Data Science tracks', () => { + const options = buildChallengeTypeOptions([ + buildChallengeType({ + abbreviation: 'CH', + id: 'challenge-id', + name: 'Challenge', + }), + buildChallengeType({ + abbreviation: 'MM', + id: 'marathon-match-id', + name: 'Marathon Match', + }), + ], buildTrack({ + name: 'Data Science', + track: 'DATA_SCIENCE', + })) + + expect(options) + .toEqual([ + { + label: 'Challenge', + value: 'challenge-id', + }, + { + label: 'Marathon Match', + value: 'marathon-match-id', + }, ]) }) }) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.ts b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.ts index c5c018cdd..0e136b9a4 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.ts +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.ts @@ -1,19 +1,26 @@ import { FormSelectOption } from '../../../../lib/components/form' +import { CREATE_CHALLENGE_TYPES_BY_TRACK } from '../../../../lib/constants/challenge-editor.constants' import { ChallengeType, Track, } from '../../../../lib/models' const TOPGEAR_TASK_ABBREVIATION = 'TGT' -const TOPGEAR_TASK_NAME = 'topgeartask' +const TOPGEAR_TASK_NAME = 'TOPGEARTASK' const MARATHON_MATCH_ABBREVIATION = 'MM' -const MARATHON_MATCH_NAME = 'marathonmatch' +const MARATHON_MATCH_NAME = 'MARATHONMATCH' const TRACK_ALIASES: Record = { + DATA_SCIENCE: 'DATA_SCIENCE', + DATASCIENCE: 'DATA_SCIENCE', DES: 'DESIGN', DESIGN: 'DESIGN', - QA: 'QUALITY_ASSURANCE', - QUALITY_ASSURANCE: 'QUALITY_ASSURANCE', - QUALITYASSURANCE: 'QUALITY_ASSURANCE', + DEV: 'DEVELOP', + DEVELOP: 'DEVELOP', + DEVELOPMENT: 'DEVELOP', + DS: 'DATA_SCIENCE', + QA: 'QA', + QUALITY_ASSURANCE: 'QA', + QUALITYASSURANCE: 'QA', } function normalizeTrackToken(value: unknown): string { @@ -27,13 +34,111 @@ function normalizeTrackToken(value: unknown): string { .replace(/\s+/g, '_') } +function normalizeChallengeTypeToken(value: unknown): string { + if (value === undefined || value === null) { + return '' + } + + return String(value) + .trim() + .toUpperCase() + .replaceAll(/[\s_-]+/g, '') +} + +/** + * Resolves a track config key to the canonical token used by work-app challenge type filters. + * + * @param track track key from `CREATE_CHALLENGE_TYPES_BY_TRACK`. + * @returns canonical track token, or an empty string when the key is blank. + * @remarks Used while loading create-challenge type allowlist config so deployment config can use + * either API tokens such as `DEVELOP` and `QA` or display tokens such as `Development`. + * @throws Does not throw. + */ +function getConfiguredTrackToken(track: string): string { + const normalizedTrack = normalizeTrackToken(track) + + if (!normalizedTrack) { + return '' + } + + return TRACK_ALIASES[normalizedTrack] || normalizedTrack +} + +/** + * Converts configured challenge type names into normalized tokens keyed by canonical track. + * + * @param challengeTypesByTrack environment-backed create-challenge type allowlist. + * @returns normalized challenge type tokens grouped by normalized track token. + * @remarks Used by `buildChallengeTypeOptions` to compare API challenge types against configured + * display names without depending on challenge type ids. + * @throws Does not throw. + */ +function buildAllowedChallengeTypeTokensByTrack( + challengeTypesByTrack: Record, +): Record> { + return Object.entries(challengeTypesByTrack) + .reduce>>((result, [track, challengeTypeNames]) => { + const normalizedTrack = getConfiguredTrackToken(track) + + if (!normalizedTrack) { + return result + } + + const typeTokens = challengeTypeNames + .map(normalizeChallengeTypeToken) + .filter(Boolean) + + if (typeTokens.length < 1) { + return result + } + + result[normalizedTrack] = new Set([ + ...Array.from(result[normalizedTrack] || []), + ...typeTokens, + ]) + + return result + }, {}) +} + +const ALLOWED_CHALLENGE_TYPE_TOKENS_BY_TRACK = buildAllowedChallengeTypeTokensByTrack( + CREATE_CHALLENGE_TYPES_BY_TRACK, +) + +const ALL_ALLOWED_CHALLENGE_TYPE_TOKENS = new Set( + Object.values(ALLOWED_CHALLENGE_TYPE_TOKENS_BY_TRACK) + .flatMap(typeTokens => Array.from(typeTokens)), +) + +/** + * Gets the configured create-challenge type tokens for a selected track. + * + * @param challengeTrack selected challenge track metadata from `useFetchChallengeTracks`. + * @returns allowed challenge type tokens for the selected track; when no track is selected, the + * union of configured type tokens across all tracks is returned. + * @remarks Used by the work challenge editor so AI-only rating challenge types and other internal + * API rows are not exposed in the create challenge dropdown. + * @throws Does not throw. + */ +function getAllowedChallengeTypeTokens( + challengeTrack?: Track, +): ReadonlySet { + const normalizedTrack = getChallengeTypeFilterTrack(challengeTrack) + + if (!normalizedTrack) { + return ALL_ALLOWED_CHALLENGE_TYPE_TOKENS + } + + return ALLOWED_CHALLENGE_TYPE_TOKENS_BY_TRACK[normalizedTrack] || new Set() +} + /** * Resolves challenge track metadata to the canonical track token used by work-manager filters. * * @param challengeTrack challenge track metadata returned by `useFetchChallengeTracks`. * @returns normalized track token, or an empty string when no track is selected. - * @remarks Used by the work challenge editor to keep challenge-type filters aligned with the - * legacy work-manager create flow for Design and QA tracks. + * @remarks Used by the work challenge editor to apply track-specific create-challenge type + * allowlists while tolerating challenge-track API naming differences. * @throws Does not throw. */ export function getChallengeTypeFilterTrack( @@ -61,13 +166,8 @@ export function getChallengeTypeFilterTrack( * @throws Does not throw. */ export function isTopgearTaskChallengeType(challengeType: ChallengeType): boolean { - const normalizedAbbreviation = (challengeType.abbreviation || '') - .trim() - .toUpperCase() - const normalizedName = (challengeType.name || '') - .replaceAll(/\s+/g, '') - .trim() - .toLowerCase() + const normalizedAbbreviation = normalizeChallengeTypeToken(challengeType.abbreviation) + const normalizedName = normalizeChallengeTypeToken(challengeType.name) return normalizedAbbreviation === TOPGEAR_TASK_ABBREVIATION || normalizedName === TOPGEAR_TASK_NAME @@ -79,30 +179,42 @@ export function isTopgearTaskChallengeType(challengeType: ChallengeType): boolea * @param challengeType challenge type metadata returned by the challenge types API. * @returns `true` when the type matches Marathon Match by abbreviation or normalized name; * otherwise `false`. - * @remarks Used by the work challenge editor to hide Marathon Match for Design and QA tracks - * while leaving the type available for tracks that still support it. + * @remarks Used by work challenge editor helpers that need Marathon Match-specific behavior. * @throws Does not throw. */ export function isMarathonMatchChallengeType(challengeType: ChallengeType): boolean { - const normalizedAbbreviation = (challengeType.abbreviation || '') - .trim() - .toUpperCase() - const normalizedName = (challengeType.name || '') - .replaceAll(/\s+/g, '') - .trim() - .toLowerCase() + const normalizedAbbreviation = normalizeChallengeTypeToken(challengeType.abbreviation) + const normalizedName = normalizeChallengeTypeToken(challengeType.name) return normalizedAbbreviation === MARATHON_MATCH_ABBREVIATION || normalizedName === MARATHON_MATCH_NAME } +/** + * Determines whether a challenge type is included in the create-challenge allowlist. + * + * @param challengeType challenge type metadata returned by the challenge types API. + * @param allowedTypeTokens configured type tokens for the selected challenge track. + * @returns `true` when the challenge type matches by normalized name or abbreviation. + * @remarks Used by the work challenge editor to hide rating-specific and internal challenge types + * that may be active in the challenge API but should not be launched from platform-ui. + * @throws Does not throw. + */ +function isAllowedCreateChallengeType( + challengeType: ChallengeType, + allowedTypeTokens: ReadonlySet, +): boolean { + return allowedTypeTokens.has(normalizeChallengeTypeToken(challengeType.name)) + || allowedTypeTokens.has(normalizeChallengeTypeToken(challengeType.abbreviation)) +} + /** * Builds the launchable challenge type options shown in the challenge editor. * * @param challengeTypes full challenge type metadata returned by `useFetchChallengeTypes`. * @param challengeTrack selected challenge track metadata from `useFetchChallengeTracks`. - * @returns active challenge types, excluding Topgear Task, sorted by display name and mapped - * to select options. Design and QA tracks also exclude Marathon Match to match work-manager. + * @returns active challenge types included in the configured create-challenge allowlist, sorted by + * display name and mapped to select options. Topgear Task remains hidden even if API data is active. * @remarks Used exclusively by `ChallengeTypeField` in the work app so users can only launch * supported challenge types from this editor. * @throws Does not throw. @@ -111,13 +223,14 @@ export function buildChallengeTypeOptions( challengeTypes: ChallengeType[], challengeTrack?: Track, ): FormSelectOption[] { - const normalizedTrack = getChallengeTypeFilterTrack(challengeTrack) - const shouldHideMarathonMatch = normalizedTrack === 'DESIGN' - || normalizedTrack === 'QUALITY_ASSURANCE' + const allowedTypeTokens = getAllowedChallengeTypeTokens(challengeTrack) return challengeTypes - .filter(type => type.isActive && !isTopgearTaskChallengeType(type)) - .filter(type => !shouldHideMarathonMatch || !isMarathonMatchChallengeType(type)) + .filter(type => ( + type.isActive + && !isTopgearTaskChallengeType(type) + && isAllowedCreateChallengeType(type, allowedTypeTokens) + )) .sort((typeA, typeB) => typeA.name.localeCompare(typeB.name)) .map(type => ({ label: type.name, diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index cbf2b351c..1e281f2e8 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -3,10 +3,36 @@ import { get } from 'lodash' import { getReactEnv } from './react-env' import type { + ChallengeTypeNamesByTrackConfig, LocalServiceOverride, SSOLoginProviderConfig, } from './global-config.model' +const DEFAULT_WORK_CREATE_CHALLENGE_TYPES_BY_TRACK: ChallengeTypeNamesByTrackConfig = { + DATA_SCIENCE: [ + 'Challenge', + 'First2Finish', + 'Marathon Match', + 'Task', + ], + DESIGN: [ + 'Challenge', + 'First2Finish', + 'Task', + ], + DEVELOP: [ + 'Challenge', + 'First2Finish', + 'Marathon Match', + 'Task', + ], + QA: [ + 'Challenge', + 'First2Finish', + 'Task', + ], +} + function parseSSOLoginProviders( raw: string | undefined, ): SSOLoginProviderConfig[] { @@ -23,6 +49,56 @@ function parseSSOLoginProviders( } } +/** + * Parses the work-app create-challenge type allowlist from a JSON environment value. + * + * @param raw JSON object whose keys are challenge tracks and values are allowed challenge type names. + * @param fallback default allowlist used when the environment value is missing or invalid. + * @returns parsed track-to-type-name config, or `fallback` when parsing cannot produce one. + * @remarks Used by `WORK_CREATE_CHALLENGE_TYPES_BY_TRACK` so deployments can hide active internal + * challenge types without changing work-app code. + * @throws Does not throw. + */ +function parseChallengeTypeNamesByTrack( + raw: string | undefined, + fallback: ChallengeTypeNamesByTrackConfig, +): ChallengeTypeNamesByTrackConfig { + if (!raw) { + return fallback + } + + try { + const parsed = JSON.parse(raw) as unknown + + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { + return fallback + } + + const config = Object.entries(parsed) + .reduce((result, [track, challengeTypes]) => { + if (!Array.isArray(challengeTypes)) { + return result + } + + const normalizedChallengeTypes = challengeTypes + .filter((challengeType): challengeType is string => ( + typeof challengeType === 'string' && challengeType.trim().length > 0 + )) + .map(challengeType => challengeType.trim()) + + if (track.trim().length > 0 && normalizedChallengeTypes.length > 0) { + result[track.trim()] = normalizedChallengeTypes + } + + return result + }, {}) + + return Object.keys(config).length > 0 ? config : fallback + } catch (error) { + return fallback + } +} + export const ENV = getReactEnv<'prod' | 'dev' | 'qa' | 'local'>( 'HOST_ENV', 'dev', @@ -51,6 +127,14 @@ export const CHALLENGE_API_VERSION: string = getReactEnv( 'CHALLENGE_API_VERSION', 'v5', ) +export const WORK_CREATE_CHALLENGE_TYPES_BY_TRACK: ChallengeTypeNamesByTrackConfig + = parseChallengeTypeNamesByTrack( + getReactEnv( + 'WORK_CREATE_CHALLENGE_TYPES_BY_TRACK', + undefined, + ), + DEFAULT_WORK_CREATE_CHALLENGE_TYPES_BY_TRACK, + ) export const COMMUNITY_APP_URL: string = getReactEnv( 'COMMUNITY_APP_URL', TOPCODER_URL, diff --git a/src/config/environments/global-config.model.ts b/src/config/environments/global-config.model.ts index ad2a38a09..98ed89c06 100644 --- a/src/config/environments/global-config.model.ts +++ b/src/config/environments/global-config.model.ts @@ -9,6 +9,8 @@ export interface LocalServiceOverride { target: string } +export type ChallengeTypeNamesByTrackConfig = Record + export interface GlobalConfig { TC_DOMAIN: string TOPCODER_URL: string @@ -17,6 +19,7 @@ export interface GlobalConfig { USER_PROFILE_URL: string CHALLENGE_API_URL?: string CHALLENGE_API_VERSION?: string + WORK_CREATE_CHALLENGE_TYPES_BY_TRACK: ChallengeTypeNamesByTrackConfig COMMUNITY_APP_URL?: string REVIEW_APP_URL?: string DIRECT_PROJECT_URL?: string From c2850e262b1da0a6f2a78126d8d5e4cdd53ac673 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 18 Jun 2026 10:27:31 +1000 Subject: [PATCH 49/73] PM-5221: show Data Science challenges as Data Science stats What was broken The previous follow-up remapped member-api DATA_SCIENCE.Challenge profile stats into Development > Code based on old production behavior. The latest Jira guidance says ratings should follow the current track/type setup, where Data Science is the track and Challenge is the type. Root cause (if identifiable) The active-track mapper converted DATA_SCIENCE.Challenge into a synthetic DEVELOP/CODE subtrack and added compatibility history/distribution metadata, so the profile stats card and detail route displayed the rating under Development Code instead of Data Science Challenge. What was changed Restored DATA_SCIENCE.Challenge as an active Data Science subtrack, with the Data Science summary using the strongest visible Data Science rating. Removed the Development Code compatibility history/distribution fields while keeping zero-submission challenge activity visible. Any added/updated tests Updated getActiveTracks coverage to expect Data Science > Challenge, including a zero-submission challenge stats row. Updated getTrackHistoryFromStats coverage for DATA_SCIENCE.Challenge keyed history. --- .../DevelopTrackView/DevelopTrackView.tsx | 6 +- .../src/hooks/useFetchActiveTracks.spec.tsx | 41 +++-- .../src/hooks/useFetchActiveTracks.tsx | 152 ++++-------------- .../src/hooks/useTrackHistory.spec.tsx | 12 +- .../profiles/src/hooks/useTrackHistory.tsx | 37 +---- src/libs/core/lib/profile/user-stats.model.ts | 17 -- 6 files changed, 57 insertions(+), 208 deletions(-) diff --git a/src/apps/profiles/src/components/tc-achievements/DevelopTrackView/DevelopTrackView.tsx b/src/apps/profiles/src/components/tc-achievements/DevelopTrackView/DevelopTrackView.tsx index 6769071cd..5b266e3ed 100644 --- a/src/apps/profiles/src/components/tc-achievements/DevelopTrackView/DevelopTrackView.tsx +++ b/src/apps/profiles/src/components/tc-achievements/DevelopTrackView/DevelopTrackView.tsx @@ -24,13 +24,11 @@ const DevelopTrackView: FC = props => { 'First2Finish', 'DESIGN_FIRST_2_FINISH', ].includes(trackName), [trackName]) - const statsDistributionTrack = props.trackData.statsDistributionTrack ?? props.trackData.parentTrack - const statsDistributionSubTrack = props.trackData.statsDistributionSubTrack ?? trackName const ratingDistribution: UserStatsDistributionResponse | undefined = useStatsDistribution( isDesignTrack ? undefined : { - subTrack: statsDistributionSubTrack, - track: statsDistributionTrack, + subTrack: trackName, + track: props.trackData.parentTrack, }, ) diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx index 7f7dcd100..f2530333a 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx @@ -74,14 +74,18 @@ describe('getActiveTracks', () => { .toEqual(['Challenge', 'Task']) }) - it('shows Data Science Challenge stats under the Development Code subtrack', () => { + it('includes Data Science Challenge stats in the Data Science track', () => { const activeTracks: MemberStatsTrack[] = getActiveTracks({ DATA_SCIENCE: { Challenge: { challenges: 1, rank: { + percentile: 10, rating: 1499, }, + submissions: { + submissions: 0, + }, wins: 1, }, MARATHON_MATCH: { @@ -98,34 +102,27 @@ describe('getActiveTracks', () => { .find(track => track.name === 'Data Science') const developmentTrack: MemberStatsTrack | undefined = activeTracks .find(track => track.name === 'Development') - const codeSubTrack = developmentTrack?.subTracks - .find(track => track.name === 'CODE') + const challengeSubTrack = dataScienceTrack?.subTracks + .find(track => track.name === 'Challenge') expect(dataScienceTrack?.challenges) + .toEqual(2) + expect(dataScienceTrack?.wins) .toEqual(1) expect(dataScienceTrack?.rating) - .toEqual(763) + .toEqual(1499) + expect(dataScienceTrack?.percentile) + .toEqual(10) expect(dataScienceTrack?.subTracks.map(track => track.name)) - .toEqual(['MARATHON_MATCH']) - expect(developmentTrack?.challenges) - .toEqual(1) - expect(developmentTrack?.wins) - .toEqual(1) - expect(developmentTrack?.subTracks.map(track => track.name)) - .toEqual(['CODE']) - expect(codeSubTrack) + .toEqual(['Challenge', 'MARATHON_MATCH']) + expect(challengeSubTrack) .toEqual(expect.objectContaining({ - historyPaths: ['DATA_SCIENCE.Challenge.history'], - name: 'CODE', - parentTrack: 'DEVELOP', - path: 'DEVELOP.subTracks', - statsDistributionSubTrack: 'Challenge', - statsDistributionTrack: 'DATA_SCIENCE', + name: 'Challenge', + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', })) - expect(codeSubTrack?.rank?.rating) - .toEqual(1499) - expect(activeTracks.map(track => track.name)) - .not.toContain('Challenge') + expect(developmentTrack) + .toBeUndefined() }) it('keeps legacy testing subtracks in the testing track', () => { diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx index bb282eccd..6ec335eff 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx @@ -29,8 +29,6 @@ const nativeDataScienceStatsKeys = new Set([ 'wins', ]) -const dataScienceChallengeCodeHistoryPath = 'DATA_SCIENCE.Challenge.history' - /** * The structure of a track for a member. */ @@ -68,7 +66,7 @@ export const getSubTrackSubmissionCount = (subTrack?: MemberStats): number | und /** * Determine whether the subtrack should be considered active. * - * Some rated Code rows can have zero submissions while still having challenge + * Some rated rows can have zero submissions while still having challenge * history, so challenge count also keeps the subtrack visible. * * @param {MemberStats | undefined} subTrack - The subtrack to inspect. @@ -95,30 +93,24 @@ const isTestingSubTrack = (subTrack?: MemberStats): boolean => ( ) /** - * Convert a Data Science Challenge stats payload into the legacy Code subtrack - * shown under Development. + * Pick the Data Science subtrack rating used by the summary card. * - * Member-api stores newly rated Data Science Challenge rows under - * `DATA_SCIENCE.Challenge`, but the profile history UI presents non-MM data - * science challenges in Development > Code for parity with existing profiles. + * Data Science can include Marathon Match and Challenge ratings. The summary + * should show the strongest visible rating instead of always using Marathon + * Match, otherwise Data Science Challenge ratings are hidden from the profile. * - * @param {UserStats | undefined} memberStats - The raw stats payload for the user. - * @returns {MemberStats | undefined} Display-ready Code stats when available. + * @param {MemberStats[]} subTracks - Active Data Science subtracks. + * @returns {MemberStats | undefined} The subtrack with the highest rating. */ -const buildDataScienceChallengeCodeSubTrack = (memberStats?: UserStats): MemberStats | undefined => { - const challengeStats = memberStats?.DATA_SCIENCE?.Challenge - - return !challengeStats ? undefined : ({ - ...challengeStats, - historyPaths: [dataScienceChallengeCodeHistoryPath], - name: 'CODE', - parentTrack: 'DEVELOP', - path: 'DEVELOP.subTracks', - statsDistributionSubTrack: 'Challenge', - statsDistributionTrack: 'DATA_SCIENCE', - submissions: challengeStats.submissions ?? { submissions: 0 }, - } as MemberStats) -} +const getDataScienceSummarySubTrack = (subTracks: MemberStats[]): MemberStats | undefined => orderBy( + subTracks, + [ + subTrack => subTrack.rank?.rating ?? 0, + subTrack => subTrack.rank?.percentile ?? 0, + subTrack => subTrack.challenges ?? 0, + ], + ['desc', 'desc', 'desc'], +)[0] /** * Attach parent track metadata to legacy design/develop subtracks and index them by name. @@ -146,96 +138,6 @@ const getFiniteNumber = (value: unknown): number | undefined => ( typeof value === 'number' && Number.isFinite(value) ? value : undefined ) -/** - * Return the rank object that should represent a merged subtrack. - * - * When both streams have ratings, the visible Code stats should keep the - * stronger rating so a Data Science Challenge rating is not hidden by a lower - * legacy Development Code rating. - * - * @param {MemberStats} currentSubTrack - Existing displayed subtrack stats. - * @param {MemberStats} additionalSubTrack - Additional stats to merge. - * @returns {MemberStats['rank']} Rank data for the merged subtrack. - */ -const getMergedSubTrackRank = ( - currentSubTrack: MemberStats, - additionalSubTrack: MemberStats, -): MemberStats['rank'] => { - const currentRating = getFiniteNumber(currentSubTrack.rank?.rating) - const additionalRating = getFiniteNumber(additionalSubTrack.rank?.rating) - - if (currentRating === undefined) { - return additionalSubTrack.rank ?? currentSubTrack.rank - } - - if (additionalRating === undefined) { - return currentSubTrack.rank - } - - return additionalRating > currentRating ? additionalSubTrack.rank : currentSubTrack.rank -} - -/** - * Merge two displayed subtracks with the same name. - * - * This keeps Development > Code as one profile bucket when a member has both - * legacy Code stats and Data Science Challenge stats backed by different API - * dimensions. - * - * @param {MemberStats} currentSubTrack - Existing displayed subtrack stats. - * @param {MemberStats} additionalSubTrack - Additional stats to merge. - * @returns {MemberStats} Merged stats for the displayed subtrack. - */ -const mergeDisplayedSubTrackStats = ( - currentSubTrack: MemberStats, - additionalSubTrack: MemberStats, -): MemberStats => { - const currentSubmissions = getSubTrackSubmissionCount(currentSubTrack) - const additionalSubmissions = getSubTrackSubmissionCount(additionalSubTrack) - const hasSubmissionCounts = currentSubmissions !== undefined || additionalSubmissions !== undefined - - return { - ...currentSubTrack, - challenges: (currentSubTrack.challenges ?? 0) + (additionalSubTrack.challenges ?? 0), - historyPaths: Array.from(new Set([ - ...(currentSubTrack.historyPaths ?? []), - ...(additionalSubTrack.historyPaths ?? []), - ])), - rank: getMergedSubTrackRank(currentSubTrack, additionalSubTrack), - submissions: hasSubmissionCounts - ? { submissions: (currentSubmissions ?? 0) + (additionalSubmissions ?? 0) } - : currentSubTrack.submissions, - wins: (currentSubTrack.wins ?? 0) + (additionalSubTrack.wins ?? 0), - } -} - -/** - * Adds Data Science Challenge stats to the Development > Code bucket. - * - * @param {{[key: string]: MemberStats}} developSubTracks - Existing Development subtracks. - * @param {UserStats | undefined} memberStats - The raw stats payload for the user. - * @returns {{[key: string]: MemberStats}} Development subtracks with the Code compatibility mapping. - */ -const addDataScienceChallengeToDevelopmentCode = ( - developSubTracks: {[key: string]: MemberStats}, - memberStats?: UserStats, -): {[key: string]: MemberStats} => { - const dataScienceChallengeCodeSubTrack = buildDataScienceChallengeCodeSubTrack(memberStats) - - if (!dataScienceChallengeCodeSubTrack) { - return developSubTracks - } - - const codeSubTrack = developSubTracks[dataScienceChallengeCodeSubTrack.name] - - return { - ...developSubTracks, - [dataScienceChallengeCodeSubTrack.name]: codeSubTrack - ? mergeDisplayedSubTrackStats(codeSubTrack, dataScienceChallengeCodeSubTrack) - : dataScienceChallengeCodeSubTrack, - } -} - /** * Determine whether a DATA_SCIENCE entry is a configured rating path. * @@ -452,6 +354,13 @@ const getDataScienceRatingPathTrackData = (memberStats?: UserStats): MemberStats export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => { // Create mappings for data science subtracks const dataScienceSubTracks: {[key: string]: MemberStats | SRMStats} = { + // Map Challenge subtrack + Challenge: (memberStats?.DATA_SCIENCE?.Challenge && ({ + ...memberStats.DATA_SCIENCE.Challenge, + name: 'Challenge', + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', + })) as MemberStats, // Map MARATHON_MATCH subtrack MARATHON_MATCH: (memberStats?.DATA_SCIENCE?.MARATHON_MATCH && ({ ...memberStats.DATA_SCIENCE.MARATHON_MATCH, @@ -476,12 +385,9 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => ) // Create mappings for develop subtracks - const developSubTracks: {[key: string]: MemberStats} = addDataScienceChallengeToDevelopmentCode( - mapSubTracksByName( - 'DEVELOP', - memberStats?.DEVELOP?.subTracks, - ), - memberStats, + const developSubTracks: {[key: string]: MemberStats} = mapSubTracksByName( + 'DEVELOP', + memberStats?.DEVELOP?.subTracks, ) // Build aggregated stats for Design, Development, Testing, and Competitive Programming tracks @@ -515,17 +421,19 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => // Data science const dsSubTracks: MemberStats[] = [ + dataScienceSubTracks.Challenge, dataScienceSubTracks.MARATHON_MATCH, ].filter(d => d?.challenges > 0) as MemberStats[] const dsTrackData: MemberStatsTrack = buildTrackData('Data Science', dsSubTracks) + const dsSummarySubTrack: MemberStats | undefined = getDataScienceSummarySubTrack(dsTrackData.subTracks) const dataScienceRatingPathTrackStats: MemberStatsTrack[] = getDataScienceRatingPathTrackData(memberStats) const dsTrackStats: MemberStatsTrack = { ...dsTrackData, isDSTrack: true, order: -1, - percentile: dataScienceSubTracks.MARATHON_MATCH?.rank?.percentile ?? 0, - rating: dataScienceSubTracks.MARATHON_MATCH?.rank?.rating ?? 0, + percentile: dsSummarySubTrack?.rank?.percentile ?? 0, + rating: dsSummarySubTrack?.rank?.rating ?? 0, } // Competitive Programming diff --git a/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx b/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx index dba34db1f..2f54d6fc4 100644 --- a/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx +++ b/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx @@ -9,12 +9,11 @@ jest.mock('~/libs/core', () => ({ }) describe('getTrackHistoryFromStats', () => { - it('reads compatibility history paths for displayed subtracks', () => { + it('reads keyed Data Science Challenge history', () => { const trackData = { - historyPaths: ['DATA_SCIENCE.Challenge.history'], - name: 'CODE', - parentTrack: 'DEVELOP', - path: 'DEVELOP.subTracks', + name: 'Challenge', + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', } as MemberStats const history = getTrackHistoryFromStats({ DATA_SCIENCE: { @@ -29,9 +28,6 @@ describe('getTrackHistoryFromStats', () => { ], }, }, - DEVELOP: { - subTracks: [], - }, groupId: 10, handle: 'testcoun', handleLower: 'testcoun', diff --git a/src/apps/profiles/src/hooks/useTrackHistory.tsx b/src/apps/profiles/src/hooks/useTrackHistory.tsx index 80a5e6cfe..388882bd7 100644 --- a/src/apps/profiles/src/hooks/useTrackHistory.tsx +++ b/src/apps/profiles/src/hooks/useTrackHistory.tsx @@ -1,19 +1,7 @@ -import { find, get, orderBy, uniqBy } from 'lodash' +import { find, get } from 'lodash' import { MemberStats, StatsHistory, UserStatsHistory, useStatsHistory } from '~/libs/core' -/** - * Extracts the history array from an exact stats-history path. - * - * @param {UserStatsHistory | undefined} statsHistory - Raw stats-history payload. - * @param {string} historyPath - Exact lodash path to a history array. - * @returns {StatsHistory[]} History rows at the supplied path. - */ -const getHistoryFromExactPath = ( - statsHistory: UserStatsHistory | undefined, - historyPath: string, -): StatsHistory[] => get(statsHistory, historyPath, []) - /** * Fetches the default history data for a track using its API path and name. * @@ -37,9 +25,6 @@ const getDefaultTrackHistory = ( /** * Extracts the history rows for a displayed track. * - * Some displayed tracks merge history from compatibility paths, such as - * Development > Code reading Data Science Challenge history from the API. - * * @param {UserStatsHistory | undefined} statsHistory - Raw stats-history payload. * @param {MemberStats | undefined} trackData - The track data for which to fetch history. * @returns {StatsHistory[]} History rows for the displayed track. @@ -52,25 +37,7 @@ export const getTrackHistoryFromStats = ( return [] } - const trackHistory = getDefaultTrackHistory(statsHistory, trackData) - const compatibilityHistory = (trackData.historyPaths ?? []) - .flatMap(historyPath => getHistoryFromExactPath(statsHistory, historyPath)) - - if (compatibilityHistory.length === 0) { - return trackHistory - } - - return orderBy( - uniqBy( - [ - ...trackHistory, - ...compatibilityHistory, - ], - history => String(history.challengeId), - ), - [history => history.ratingDate ?? history.date ?? 0], - ['desc'], - ) + return getDefaultTrackHistory(statsHistory, trackData) } /** diff --git a/src/libs/core/lib/profile/user-stats.model.ts b/src/libs/core/lib/profile/user-stats.model.ts index 5f2d15ba3..d251fc80e 100644 --- a/src/libs/core/lib/profile/user-stats.model.ts +++ b/src/libs/core/lib/profile/user-stats.model.ts @@ -55,23 +55,6 @@ export type MemberStats = { } parentTrack?: string path?: string - /** - * Exact stats-history paths to merge into this displayed subtrack. - * - * Used when a legacy display bucket is backed by stats stored under a newer - * API dimension. - */ - historyPaths?: string[] - /** - * Track key used for rating-distribution lookups when it differs from the - * displayed parent track. - */ - statsDistributionTrack?: string - /** - * Subtrack key used for rating-distribution lookups when it differs from - * the displayed subtrack name. - */ - statsDistributionSubTrack?: string } /** From 5bafb56d38962e9e630e0decb60690b8570f78fe Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 18 Jun 2026 10:38:31 +1000 Subject: [PATCH 50/73] Tweaks for test submission flow in MM setup --- .../src/lib/models/MarathonMatch.model.ts | 25 +- .../services/marathon-match.service.spec.ts | 68 +++- .../lib/services/marathon-match.service.ts | 110 +++++- .../MarathonMatchScorerSection.module.scss | 69 ++++ .../MarathonMatchScorerSection.tsx | 354 +++++++++++++++++- 5 files changed, 600 insertions(+), 26 deletions(-) diff --git a/src/apps/work/src/lib/models/MarathonMatch.model.ts b/src/apps/work/src/lib/models/MarathonMatch.model.ts index 40f21ef04..1d72bfeab 100644 --- a/src/apps/work/src/lib/models/MarathonMatch.model.ts +++ b/src/apps/work/src/lib/models/MarathonMatch.model.ts @@ -166,15 +166,38 @@ export interface MarathonMatchTestSubmissionInput { /** * Response returned after uploading one Marathon Match validation submission. - * Used by the scorer section to show the created submission and queued ECS task. + * Used by the scorer section to start polling the isolated validation run. */ export interface MarathonMatchTestSubmissionResponse { challengeId: string cloudWatchLogsConsoleUrl?: string configType: MarathonMatchConfigType + status: string submissionId: string taskArn: string taskId: string + testSubmissionId: string +} + +/** + * Current status and final scorer details for an isolated validation submission run. + * Used by the scorer section result modal after polling completes. + */ +export interface MarathonMatchTestSubmissionStatusResponse extends MarathonMatchTestSubmissionResponse { + completedAt?: string + completedTests?: number + currentReview?: Record + failedTests?: number + fileName: string + fileSize: number + impactedReviews?: Record[] + memberId?: string + message?: string + metadata?: Record + progress?: number + score?: number + totalTests?: number + updatedAt: string } /** diff --git a/src/apps/work/src/lib/services/marathon-match.service.spec.ts b/src/apps/work/src/lib/services/marathon-match.service.spec.ts index 0b228fe84..ffe36d36e 100644 --- a/src/apps/work/src/lib/services/marathon-match.service.spec.ts +++ b/src/apps/work/src/lib/services/marathon-match.service.spec.ts @@ -1,7 +1,13 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ -import { xhrPostAsync } from '~/libs/core' +import { + xhrGetAsync, + xhrPostAsync, +} from '~/libs/core' -import { uploadMarathonMatchTestSubmission } from './marathon-match.service' +import { + fetchMarathonMatchTestSubmissionStatus, + uploadMarathonMatchTestSubmission, +} from './marathon-match.service' jest.mock('~/libs/core', () => ({ xhrGetAsync: jest.fn(), @@ -30,9 +36,11 @@ describe('marathon-match.service', () => { challengeId: '30000123', cloudWatchLogsConsoleUrl: 'https://logs.example.com/task-1', configType: 'PROVISIONAL', - submissionId: 'submission-1', + status: 'QUEUED', + submissionId: 'validation-run-1', taskArn: 'arn:aws:ecs:task/task-1', taskId: 'task-1', + testSubmissionId: 'validation-run-1', }) await expect( @@ -46,9 +54,11 @@ describe('marathon-match.service', () => { challengeId: '30000123', cloudWatchLogsConsoleUrl: 'https://logs.example.com/task-1', configType: 'PROVISIONAL', - submissionId: 'submission-1', + status: 'QUEUED', + submissionId: 'validation-run-1', taskArn: 'arn:aws:ecs:task/task-1', taskId: 'task-1', + testSubmissionId: 'validation-run-1', }) expect(mockedPostAsync) @@ -92,4 +102,54 @@ describe('marathon-match.service', () => { .rejects .toThrow('Marathon match validation upload response was invalid') }) + + it('fetches validation submission status details', async () => { + const mockedGetAsync = xhrGetAsync as jest.Mock + + mockedGetAsync.mockResolvedValue({ + challengeId: '30000123', + cloudWatchLogsConsoleUrl: 'https://logs.example.com/task-1', + completedAt: '2026-06-17T01:02:03.000Z', + completedTests: 50, + configType: 'PROVISIONAL', + failedTests: 0, + fileName: 'solution.zip', + fileSize: 3, + metadata: { + testType: 'provisional', + }, + progress: 1, + score: 88.5, + status: 'SUCCESS', + submissionId: 'validation-run-1', + taskArn: 'arn:aws:ecs:task/task-1', + taskId: 'task-1', + testSubmissionId: 'validation-run-1', + totalTests: 50, + updatedAt: '2026-06-17T01:02:03.000Z', + }) + + await expect( + fetchMarathonMatchTestSubmissionStatus( + '30000123', + 'validation-run-1', + ), + ) + .resolves + .toMatchObject({ + challengeId: '30000123', + fileName: 'solution.zip', + metadata: { + testType: 'provisional', + }, + score: 88.5, + status: 'SUCCESS', + testSubmissionId: 'validation-run-1', + }) + + expect(mockedGetAsync) + .toHaveBeenCalledWith( + 'https://example.com/marathon-match/challenge/30000123/test-submission/validation-run-1', + ) + }) }) diff --git a/src/apps/work/src/lib/services/marathon-match.service.ts b/src/apps/work/src/lib/services/marathon-match.service.ts index 8c8f4871f..855a1f649 100644 --- a/src/apps/work/src/lib/services/marathon-match.service.ts +++ b/src/apps/work/src/lib/services/marathon-match.service.ts @@ -24,6 +24,7 @@ import { MarathonMatchTesterSummary, MarathonMatchTestSubmissionInput, MarathonMatchTestSubmissionResponse, + MarathonMatchTestSubmissionStatusResponse, UpdateMarathonMatchConfigInput, } from '../models' @@ -44,6 +45,36 @@ export interface FetchTestersParams { type TestSubmissionResponseConfigType = MarathonMatchTestSubmissionResponse['configType'] +/** + * Normalizes a JSON-like value into a plain object record. + * @param value Candidate API payload fragment. + * @returns Record value when the fragment is an object; otherwise `undefined`. + * Used by validation-submission status normalization for metadata fields. + */ +function normalizeRecord(value: unknown): Record | undefined { + if (typeof value !== 'object' || !value || Array.isArray(value)) { + return undefined + } + + return value as Record +} + +/** + * Normalizes a JSON-like value into an array of plain object records. + * @param value Candidate API payload fragment. + * @returns Record array when the fragment is an array of objects; otherwise `undefined`. + * Used by validation-submission status normalization for impacted review details. + */ +function normalizeRecordArray(value: unknown): Record[] | undefined { + if (!Array.isArray(value)) { + return undefined + } + + return value + .map(normalizeRecord) + .filter((entry): entry is Record => !!entry) +} + function normalizeText(value: unknown): string | undefined { if (value === undefined || value === null) { return undefined @@ -488,10 +519,12 @@ function normalizeMarathonMatchTestSubmissionResponse( const challengeId = normalizeText(typedResponse.challengeId) const configType = normalizeText(typedResponse.configType) as TestSubmissionResponseConfigType | undefined const submissionId = normalizeText(typedResponse.submissionId) + const testSubmissionId = normalizeText(typedResponse.testSubmissionId) || submissionId + const status = normalizeText(typedResponse.status) const taskArn = normalizeText(typedResponse.taskArn) const taskId = normalizeText(typedResponse.taskId) - if (!challengeId || !configType || !submissionId || !taskArn || !taskId) { + if (!challengeId || !configType || !submissionId || !testSubmissionId || !status || !taskArn || !taskId) { return undefined } @@ -499,9 +532,54 @@ function normalizeMarathonMatchTestSubmissionResponse( challengeId, cloudWatchLogsConsoleUrl: normalizeText(typedResponse.cloudWatchLogsConsoleUrl), configType, + status, submissionId, taskArn, taskId, + testSubmissionId, + } +} + +/** + * Normalizes a raw validation submission status response. + * @param response Raw response from GET /challenge/:challengeId/test-submission/:testSubmissionId. + * @returns Normalized status response when required fields are present; otherwise `undefined`. + * Used by `fetchMarathonMatchTestSubmissionStatus` before resolving the API call. + */ +function normalizeMarathonMatchTestSubmissionStatusResponse( + response: unknown, +): MarathonMatchTestSubmissionStatusResponse | undefined { + const baseResponse = normalizeMarathonMatchTestSubmissionResponse(response) + + if (!baseResponse || typeof response !== 'object' || !response) { + return undefined + } + + const typedResponse = response as Record + const fileName = normalizeText(typedResponse.fileName) + const fileSize = normalizeNumber(typedResponse.fileSize) + const updatedAt = normalizeText(typedResponse.updatedAt) + + if (!fileName || fileSize === undefined || !updatedAt) { + return undefined + } + + return { + ...baseResponse, + completedAt: normalizeText(typedResponse.completedAt), + completedTests: normalizeNumber(typedResponse.completedTests), + currentReview: normalizeRecord(typedResponse.currentReview), + failedTests: normalizeNumber(typedResponse.failedTests), + fileName, + fileSize, + impactedReviews: normalizeRecordArray(typedResponse.impactedReviews), + memberId: normalizeText(typedResponse.memberId), + message: normalizeText(typedResponse.message), + metadata: normalizeRecord(typedResponse.metadata), + progress: normalizeNumber(typedResponse.progress), + score: normalizeNumber(typedResponse.score), + totalTests: normalizeNumber(typedResponse.totalTests), + updatedAt, } } @@ -741,6 +819,36 @@ export async function uploadMarathonMatchTestSubmission( } } +/** + * Fetches the current status for an isolated marathon match validation submission run. + * @param challengeId Challenge identifier used in the validation status route path. + * @param testSubmissionId Validation run identifier returned by upload. + * @returns Current validation run progress and final scoring details when complete. + * @throws Error When the API request fails or returns an invalid status response. + * Used by `MarathonMatchScorerSection` to poll until validation scoring completes. + */ +export async function fetchMarathonMatchTestSubmissionStatus( + challengeId: string, + testSubmissionId: string, +): Promise { + try { + const encodedChallengeId = encodeURIComponent(challengeId.trim()) + const encodedTestSubmissionId = encodeURIComponent(testSubmissionId.trim()) + const response = await xhrGetAsync( + `${MARATHON_MATCH_API_URL}/challenge/${encodedChallengeId}/test-submission/${encodedTestSubmissionId}`, + ) + const normalizedResponse = normalizeMarathonMatchTestSubmissionStatusResponse(response) + + if (!normalizedResponse) { + throw new Error('Marathon match validation submission status response was invalid') + } + + return normalizedResponse + } catch (error) { + throw normalizeError(error, 'Failed to fetch marathon match validation submission status') + } +} + /** * Lists available testers for scorer configuration. * @param params Optional tester-name filter and pagination controls. diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss index 0925117e9..0fced2607 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss @@ -343,6 +343,75 @@ gap: 16px; } +.resultGrid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); +} + +.resultItem, +.resultMeta { + background: #f6f8fa; + border: 1px solid $black-10; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; +} + +.resultItem span, +.resultMeta span, +.resultJsonSection span { + color: $black-60; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.resultItem strong, +.resultMeta strong { + color: $black-80; + font-size: 14px; + overflow-wrap: anywhere; +} + +.resultMessage { + background: #eef4ff; + border: 1px solid #bfd0f9; + border-radius: 8px; + color: #2f4b84; + font-size: 14px; + line-height: 1.5; + padding: 12px; +} + +.resultLink { + color: $link-blue-dark; + font-weight: 600; +} + +.resultJsonSection { + display: flex; + flex-direction: column; + gap: 8px; +} + +.resultJsonSection pre { + background: #101820; + border-radius: 8px; + color: #f3f7fb; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + line-height: 1.5; + margin: 0; + max-height: 300px; + overflow: auto; + padding: 12px; + white-space: pre-wrap; + word-break: break-word; +} + .compilationErrorMeta { display: flex; flex-direction: column; diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx index 370689417..dbacab6fa 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx @@ -21,12 +21,14 @@ import { MarathonMatchTester, MarathonMatchTesterSummary, MarathonMatchTestSubmissionResponse, + MarathonMatchTestSubmissionStatusResponse, UpdateMarathonMatchConfigInput, } from '../../../../../lib/models' import { createMarathonMatchConfig, fetchMarathonMatchConfig, fetchMarathonMatchDefaults, + fetchMarathonMatchTestSubmissionStatus, fetchTester, fetchTesters, rerunMarathonMatchScores, @@ -34,6 +36,7 @@ import { uploadMarathonMatchTestSubmission, } from '../../../../../lib/services' import { + formatDateTime, showErrorToast, showInfoToast, showSuccessToast, @@ -45,6 +48,7 @@ import styles from './MarathonMatchScorerSection.module.scss' const DEFAULT_SCORER_NAME = 'Marathon Match Scorer' const DEFAULT_SCORE_DIRECTION = 'MAXIMIZE' const POLL_INTERVAL_MS = 5000 +const TEST_SUBMISSION_TERMINAL_STATUSES = new Set(['SUCCESS', 'FAILED']) const PHASE_LABELS = { example: 'Example', provisional: 'Provisional', @@ -119,6 +123,106 @@ interface TestSubmissionPhaseOption { value: MarathonMatchConfigType } +type TestSubmissionResultState = + | MarathonMatchTestSubmissionResponse + | MarathonMatchTestSubmissionStatusResponse + | undefined + +/** + * Normalizes a validation submission status for comparisons and display. + * @param status Raw status returned by marathon-match-api. + * @returns Uppercase status text, or `UNKNOWN` when absent. + * Used by validation-run polling and modal rendering. + */ +function normalizeTestSubmissionStatus(status?: string): string { + const normalizedStatus = status?.trim() + .toUpperCase() + + return normalizedStatus || 'UNKNOWN' +} + +/** + * Checks whether validation submission polling should stop. + * @param status Raw status returned by marathon-match-api. + * @returns True when the validation run reached a terminal state. + * Used by `pollTestSubmissionStatus` after each status response. + */ +function isTerminalTestSubmissionStatus(status?: string): boolean { + return TEST_SUBMISSION_TERMINAL_STATUSES.has(normalizeTestSubmissionStatus(status)) +} + +/** + * Formats a numeric score for the validation result modal. + * @param score Optional final aggregate score. + * @returns Display-ready score text. + * Used by `renderTestSubmissionResultModal`. + */ +function formatTestSubmissionScore(score?: number): string { + return typeof score === 'number' && Number.isFinite(score) + ? score.toLocaleString(undefined, { maximumFractionDigits: 6 }) + : 'Not available' +} + +/** + * Formats a validation-run progress value as a percentage. + * @param progress Optional progress value from 0 to 1. + * @returns Display-ready progress text. + * Used by inline validation status and the result modal. + */ +function formatTestSubmissionProgress(progress?: number): string { + if (typeof progress !== 'number' || !Number.isFinite(progress)) { + return 'Pending' + } + + return `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%` +} + +/** + * Formats completed/total/failed testcase counts. + * @param result Validation run status response with optional testcase counters. + * @returns Compact testcase count summary. + * Used by `renderTestSubmissionResultModal`. + */ +function formatTestSubmissionTests(result: MarathonMatchTestSubmissionStatusResponse): string { + const completed = result.completedTests ?? 0 + const total = result.totalTests ?? 0 + const failed = result.failedTests ?? 0 + + if (!total) { + return failed + ? `${failed} failed` + : 'Not available' + } + + return `${completed}/${total} complete${failed ? `, ${failed} failed` : ''}` +} + +/** + * Formats validation-run metadata for a read-only modal preview. + * @param value Optional metadata object or array returned by the scorer. + * @returns Pretty JSON text, or an empty string when no metadata exists. + * Used by `renderJsonPreview`. + */ +function formatJsonPreview(value?: Record | Record[]): string { + if (!value) { + return '' + } + + return JSON.stringify(value, undefined, 2) +} + +/** + * Checks whether a validation result includes full status fields. + * @param result Upload response or status response stored in component state. + * @returns True when the response has final/polling fields from the status endpoint. + * Used before rendering the validation result modal. + */ +function isTestSubmissionStatusResponse( + result: MarathonMatchTestSubmissionResponse | MarathonMatchTestSubmissionStatusResponse | undefined, +): result is MarathonMatchTestSubmissionStatusResponse { + return !!result && 'fileName' in result +} + interface PhaseConfigCardProps { errors: PhaseValidationErrors label: string @@ -736,6 +840,7 @@ export const MarathonMatchScorerSection: FC = ( const phaseListRef = useRef(phases) const isMountedRef = useRef(true) const pollingTimerRef = useRef() + const testSubmissionPollingTimerRef = useRef() const [config, setConfig] = useState() const [defaults, setDefaults] = useState() @@ -764,7 +869,8 @@ export const MarathonMatchScorerSection: FC = ( const [testerLoadError, setTesterLoadError] = useState() const [selectedTestSubmissionFile, setSelectedTestSubmissionFile] = useState() const [testSubmissionConfigType, setTestSubmissionConfigType] = useState('PROVISIONAL') - const [testSubmissionResult, setTestSubmissionResult] = useState() + const [testSubmissionResult, setTestSubmissionResult] = useState() + const [showTestSubmissionResultModal, setShowTestSubmissionResultModal] = useState(false) const [showNewTesterModal, setShowNewTesterModal] = useState(false) const [showNewVersionModal, setShowNewVersionModal] = useState(false) const [showCompilationErrorsModal, setShowCompilationErrorsModal] = useState(false) @@ -847,6 +953,20 @@ export const MarathonMatchScorerSection: FC = ( pollingTimerRef.current = undefined }, []) + /** + * Clears the active validation-submission polling timeout. + * @returns void + * Used when a new validation upload starts, the selected file changes, or the component unmounts. + */ + const clearTestSubmissionPollingTimer = useCallback((): void => { + if (testSubmissionPollingTimerRef.current === undefined) { + return + } + + window.clearTimeout(testSubmissionPollingTimerRef.current) + testSubmissionPollingTimerRef.current = undefined + }, []) + const handleOpenNewTesterModal = useCallback((): void => { setShowNewTesterModal(true) }, []) @@ -1181,8 +1301,10 @@ export const MarathonMatchScorerSection: FC = ( setSelectedTestSubmissionFile(nextFile) setTestSubmissionError(undefined) setTestSubmissionResult(undefined) + setShowTestSubmissionResultModal(false) + clearTestSubmissionPollingTimer() }, - [], + [clearTestSubmissionPollingTimer], ) useEffect(() => { @@ -1191,7 +1313,8 @@ export const MarathonMatchScorerSection: FC = ( useEffect(() => () => { isMountedRef.current = false - }, []) + clearTestSubmissionPollingTimer() + }, [clearTestSubmissionPollingTimer]) useEffect(() => { let isMounted = true @@ -1203,7 +1326,9 @@ export const MarathonMatchScorerSection: FC = ( setRerunError(undefined) setTestSubmissionError(undefined) setTestSubmissionResult(undefined) + setShowTestSubmissionResultModal(false) setSelectedTestSubmissionFile(undefined) + clearTestSubmissionPollingTimer() setTesterLoadError(undefined) setSelectedTester(undefined) @@ -1258,9 +1383,11 @@ export const MarathonMatchScorerSection: FC = ( return () => { isMounted = false clearPollingTimer() + clearTestSubmissionPollingTimer() } }, [ clearPollingTimer, + clearTestSubmissionPollingTimer, loadTesterById, challengeId, ]) @@ -1426,6 +1553,73 @@ export const MarathonMatchScorerSection: FC = ( && !isUploadingTestSubmission && !!selectedTestSubmissionFile && testSubmissionPhaseOptions.some(option => option.value === testSubmissionConfigType) + const testSubmissionStatus = normalizeTestSubmissionStatus(testSubmissionResult?.status) + const isTestSubmissionComplete = isTerminalTestSubmissionStatus(testSubmissionResult?.status) + const modalTestSubmissionResult = isTestSubmissionStatusResponse(testSubmissionResult) + ? testSubmissionResult + : undefined + + /** + * Starts polling a validation submission run until scoring reaches a terminal state. + * @param testSubmissionId Validation run identifier returned by upload. + * @returns void + * Used by `handleUploadTestSubmission` after the ECS task is queued. + */ + const startTestSubmissionStatusPolling = useCallback((testSubmissionId: string): void => { + clearTestSubmissionPollingTimer() + + const pollStatus = async (): Promise => { + try { + const statusResponse = await fetchMarathonMatchTestSubmissionStatus( + challengeId, + testSubmissionId, + ) + + if (!isMountedRef.current) { + return + } + + setTestSubmissionResult(statusResponse) + + if (isTerminalTestSubmissionStatus(statusResponse.status)) { + clearTestSubmissionPollingTimer() + setShowTestSubmissionResultModal(true) + + if (normalizeTestSubmissionStatus(statusResponse.status) === 'FAILED') { + showErrorToast('Validation scoring failed') + } else { + showSuccessToast('Validation scoring complete') + } + + return + } + + testSubmissionPollingTimerRef.current = window.setTimeout(() => { + pollStatus() + .catch(() => undefined) + }, POLL_INTERVAL_MS) + } catch (error) { + if (!isMountedRef.current) { + return + } + + const errorMessage = getErrorMessage( + error, + 'Failed to fetch marathon match validation submission status', + ) + + clearTestSubmissionPollingTimer() + setTestSubmissionError(errorMessage) + showErrorToast(errorMessage) + } + } + + pollStatus() + .catch(() => undefined) + }, [ + challengeId, + clearTestSubmissionPollingTimer, + ]) const handleUploadTestSubmission = useCallback( async (): Promise => { @@ -1433,9 +1627,11 @@ export const MarathonMatchScorerSection: FC = ( return } + clearTestSubmissionPollingTimer() setIsUploadingTestSubmission(true) setTestSubmissionError(undefined) setTestSubmissionResult(undefined) + setShowTestSubmissionResultModal(false) try { const uploadResponse = await uploadMarathonMatchTestSubmission( @@ -1447,7 +1643,8 @@ export const MarathonMatchScorerSection: FC = ( ) setTestSubmissionResult(uploadResponse) - showSuccessToast('Validation submission queued for scoring') + showSuccessToast('Validation scoring queued') + startTestSubmissionStatusPolling(uploadResponse.testSubmissionId) } catch (error) { const errorMessage = getErrorMessage( error, @@ -1463,7 +1660,9 @@ export const MarathonMatchScorerSection: FC = ( [ canUploadTestSubmission, challengeId, + clearTestSubmissionPollingTimer, selectedTestSubmissionFile, + startTestSubmissionStatusPolling, testSubmissionConfigType, ], ) @@ -1473,6 +1672,15 @@ export const MarathonMatchScorerSection: FC = ( .catch(() => undefined) }, [handleUploadTestSubmission]) + /** + * Closes the validation submission result modal. + * @returns void + * Used by the modal close button and footer action. + */ + const handleCloseTestSubmissionResultModal = useCallback((): void => { + setShowTestSubmissionResultModal(false) + }, []) + const handleRerunScores = useCallback( async (): Promise => { if (!canRerunScores) { @@ -1728,30 +1936,23 @@ export const MarathonMatchScorerSection: FC = (
    - {testSubmissionResult + {testSubmissionResult && !isTestSubmissionComplete ? (
    - Submission + Validation scoring {' '} - {testSubmissionResult.submissionId} + {testSubmissionStatus.toLowerCase()} - Task + Progress {' '} - {testSubmissionResult.taskId} + {formatTestSubmissionProgress( + isTestSubmissionStatusResponse(testSubmissionResult) + ? testSubmissionResult.progress + : undefined, + )} - {testSubmissionResult.cloudWatchLogsConsoleUrl - ? ( - - CloudWatch logs - - ) - : undefined}
    ) : undefined} @@ -1956,6 +2157,119 @@ export const MarathonMatchScorerSection: FC = ( />
    + {showTestSubmissionResultModal && modalTestSubmissionResult + ? ( + + )} + onClose={handleCloseTestSubmissionResultModal} + open + size='md' + title={ + normalizeTestSubmissionStatus(modalTestSubmissionResult.status) === 'FAILED' + ? 'Validation Scoring Failed' + : 'Validation Scoring Complete' + } + > +
    +
    +
    + Status + {normalizeTestSubmissionStatus(modalTestSubmissionResult.status)} +
    +
    + Score + {formatTestSubmissionScore(modalTestSubmissionResult.score)} +
    +
    + Phase + {modalTestSubmissionResult.configType} +
    +
    + Tests + {formatTestSubmissionTests(modalTestSubmissionResult)} +
    +
    + Progress + {formatTestSubmissionProgress(modalTestSubmissionResult.progress)} +
    +
    + Completed + {formatDateTime(modalTestSubmissionResult.completedAt)} +
    +
    + + {modalTestSubmissionResult.message + ? ( +
    + {modalTestSubmissionResult.message} +
    + ) + : undefined} + +
    + File + {modalTestSubmissionResult.fileName} +
    + + {modalTestSubmissionResult.taskId + ? ( +
    + Task + {modalTestSubmissionResult.taskId} +
    + ) + : undefined} + + {modalTestSubmissionResult.cloudWatchLogsConsoleUrl + ? ( + + Open CloudWatch logs + + ) + : undefined} + + {formatJsonPreview(modalTestSubmissionResult.metadata) + ? ( +
    + Metadata +
    {formatJsonPreview(modalTestSubmissionResult.metadata)}
    +
    + ) + : undefined} + + {formatJsonPreview(modalTestSubmissionResult.currentReview) + ? ( +
    + Current Review +
    {formatJsonPreview(modalTestSubmissionResult.currentReview)}
    +
    + ) + : undefined} + + {formatJsonPreview(modalTestSubmissionResult.impactedReviews) + ? ( +
    + Impacted Reviews +
    {formatJsonPreview(modalTestSubmissionResult.impactedReviews)}
    +
    + ) + : undefined} +
    +
    + ) + : undefined} + {showNewTesterModal ? ( Date: Thu, 18 Jun 2026 10:50:21 +1000 Subject: [PATCH 51/73] PM-5223: show QA stats in Testing What was broken The profile app only typed and read DEVELOP, DESIGN, and DATA_SCIENCE stats. Once the API emits first-class QA stats for QA Challenge ratings, those stats would not appear in the profile's Testing section. Root cause The profile stats models and active-track builder did not include a QA stats/history group or map QA Challenge subtracks into the displayed Testing track. What was changed Added QA to the profile stats/history types and active track model, then merged QA subtracks into the visible Testing track while preserving QA history paths for the stats history drawer. Any added/updated tests Added profile hook coverage for rendering QA Challenge stats under Testing and reading QA subtrack history from QA.subTracks. --- .../src/hooks/useFetchActiveTracks.spec.tsx | 42 +++++++++++++++++++ .../src/hooks/useFetchActiveTracks.tsx | 12 +++++- .../src/hooks/useTrackHistory.spec.tsx | 37 ++++++++++++++++ .../core/lib/profile/user-profile.model.ts | 2 +- src/libs/core/lib/profile/user-stats.model.ts | 13 ++++++ 5 files changed, 103 insertions(+), 3 deletions(-) diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx index 7f7dcd100..990039db3 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx @@ -166,6 +166,48 @@ describe('getActiveTracks', () => { .toEqual(['BUG_HUNT']) }) + it('shows QA Challenge stats in the testing track', () => { + const activeTracks: MemberStatsTrack[] = getActiveTracks({ + QA: { + challenges: 1, + mostRecentEventDate: 1781237773026, + mostRecentSubmission: 1781237773026, + subTracks: [ + { + challenges: 1, + name: 'Challenge', + rank: { + rating: 1490, + }, + submissions: { + submissions: 1, + }, + wins: 1, + }, + ], + wins: 1, + }, + } as UserStats) + const testingTrack: MemberStatsTrack | undefined = activeTracks + .find(track => track.name === 'Testing') + const qaChallengeSubTrack = testingTrack?.subTracks + .find(track => track.name === 'Challenge') + + expect(testingTrack) + .toEqual(expect.objectContaining({ + challenges: 1, + wins: 1, + })) + expect(qaChallengeSubTrack) + .toEqual(expect.objectContaining({ + name: 'Challenge', + parentTrack: 'QA', + path: 'QA.subTracks', + })) + expect(qaChallengeSubTrack?.rank?.rating) + .toEqual(1490) + }) + it('keeps AI engineering stats visible when the API returns them', () => { const memberStats = { AI_ENGINEERING: { diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx index bb282eccd..3dc63651f 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx @@ -484,6 +484,11 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => memberStats, ) + const qaSubTracks: {[key: string]: MemberStats} = mapSubTracksByName( + 'QA', + memberStats?.QA?.subTracks, + ) + // Build aggregated stats for Design, Development, Testing, and Competitive Programming tracks // AI Engineering const aiEngineeringTrackStats: MemberStatsTrack = buildAIEngineeringTrackData(memberStats) @@ -508,8 +513,11 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => const testingTrackStats: MemberStatsTrack = ( buildTrackData( 'Testing', - Object.values(developSubTracks) - .filter(isTestingSubTrack), + [ + ...Object.values(developSubTracks) + .filter(isTestingSubTrack), + ...Object.values(qaSubTracks), + ], ) ) diff --git a/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx b/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx index dba34db1f..bdb08a8c4 100644 --- a/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx +++ b/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx @@ -46,4 +46,41 @@ describe('getTrackHistoryFromStats', () => { }), ]) }) + + it('reads QA subtrack history paths', () => { + const trackData = { + name: 'Challenge', + parentTrack: 'QA', + path: 'QA.subTracks', + } as MemberStats + const history = getTrackHistoryFromStats({ + groupId: 10, + handle: 'testmfa6', + handleLower: 'testmfa6', + QA: { + subTracks: [ + { + history: [ + { + challengeId: 'qa-challenge', + challengeName: 'QA Challenge', + newRating: 1490, + ratingDate: 1781237773026, + }, + ], + name: 'Challenge', + }, + ], + }, + userId: 89770374, + } as UserStatsHistory, trackData) + + expect(history) + .toEqual([ + expect.objectContaining({ + challengeId: 'qa-challenge', + newRating: 1490, + }), + ]) + }) }) diff --git a/src/libs/core/lib/profile/user-profile.model.ts b/src/libs/core/lib/profile/user-profile.model.ts index 61b1c3061..3f2df25ff 100644 --- a/src/libs/core/lib/profile/user-profile.model.ts +++ b/src/libs/core/lib/profile/user-profile.model.ts @@ -1,6 +1,6 @@ import { UserSkill } from './user-skill.model' -export type TC_TRACKS = 'DEVELOP' | 'DESIGN' | 'DATA_SCIENCE' +export type TC_TRACKS = 'DEVELOP' | 'DESIGN' | 'DATA_SCIENCE' | 'QA' export enum NamesAndHandleAppearance { both = 'namesAndHandle', diff --git a/src/libs/core/lib/profile/user-stats.model.ts b/src/libs/core/lib/profile/user-stats.model.ts index 5f2d15ba3..82ab87c28 100644 --- a/src/libs/core/lib/profile/user-stats.model.ts +++ b/src/libs/core/lib/profile/user-stats.model.ts @@ -149,6 +149,13 @@ export type UserStats = { subTracks: Array wins: number } + QA?: { + challenges: number + mostRecentEventDate: number + mostRecentSubmission: number + subTracks: Array + wins: number + } AI?: MemberStatsGroup AI_ENGINEER?: MemberStatsGroup AI_ENGINEERING?: MemberStatsGroup @@ -193,4 +200,10 @@ export type UserStatsHistory = { history: Array }> } + QA?: { + subTracks: Array<{ + name: string + history: Array + }> + } } From 2c8e18484855b7ac4e4a76deb08676b53948d0d0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 18 Jun 2026 11:28:27 +1000 Subject: [PATCH 52/73] PM-5231: Show failed validation scores What was broken The Work Manager Submissions tab could still display a blank score for Marathon Match validation uploads when the scorer completed with a failed aggregate score such as -1. Root cause (if identifiable) The previous PM-5231 UI follow-up added Example phase display support, but the shared review-summation score helper still rejected all negative aggregate scores. Marathon Match uses -1 as the failed scorer result, so failed Example, Provisional, or System validation scores were filtered out before the table rendered them. What was changed Kept the default non-negative score behavior for standard score paths, and added an explicit Marathon Match validation display path that retains finite negative scorer results. The submissions table now opts into that behavior for Marathon Match Provisional/System display, while Example scores retain the failed result as well. Any added/updated tests Added utility coverage for failed Example, Provisional, and System validation scores, plus table coverage that renders a failed Example score as -1.00 with the Example process and failed status. --- .../SubmissionsTable.spec.tsx | 43 +++++++++++++++++ .../SubmissionsTable/SubmissionsTable.tsx | 6 +-- .../src/lib/utils/challenge.utils.spec.ts | 48 +++++++++++++++++++ .../work/src/lib/utils/challenge.utils.ts | 21 +++++++- 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx index 5d383fceb..1fd03c1d0 100644 --- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx +++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx @@ -441,4 +441,47 @@ describe('SubmissionsTable', () => { expect(screen.getByRole('img', { name: 'Test status: SUCCESS' })) .toBeTruthy() }) + + it('renders failed example validation scores for marathon submissions', () => { + render( + , + ) + + expect(screen.getByRole('link', { name: '-1.00 / -' })) + .toBeTruthy() + expect(screen.getByText('Example')) + .toBeTruthy() + expect(screen.getByText('100%')) + .toBeTruthy() + expect(screen.getByRole('img', { name: 'Test status: FAILED' })) + .toBeTruthy() + }) }) diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx index 955a527fa..7b92996c7 100644 --- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx +++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx @@ -168,7 +168,7 @@ function formatTestProcess(process?: string): string { * Selects the marathon score shown in the left side of the score column. * @param submission Submission row with review summation data. * @param process Current marathon test process from progress metadata. - * @returns Example score when Example is current; otherwise the provisional score. + * @returns Example score when Example is current; otherwise the provisional score, including MM failure scores. * Used by `SubmissionsTable` for marathon score display. */ function getMarathonInitialScore( @@ -183,7 +183,7 @@ function getMarathonInitialScore( } } - return getSubmissionProvisionalScore(submission) + return getSubmissionProvisionalScore(submission, true) } function getSortIndicator( @@ -379,7 +379,7 @@ export const SubmissionsTable: FC = ( ? getMarathonInitialScore(submission, testProgress?.process) : getSubmissionInitialScore(submission) const finalScoreValue = props.showMarathonMatchTestProgress - ? getSubmissionSystemScore(submission) + ? getSubmissionSystemScore(submission, true) : getSubmissionFinalScore(submission) const emptyScoreValue = props.showMarathonMatchTestProgress ? '-' diff --git a/src/apps/work/src/lib/utils/challenge.utils.spec.ts b/src/apps/work/src/lib/utils/challenge.utils.spec.ts index 70fb4a35f..535bf3a99 100644 --- a/src/apps/work/src/lib/utils/challenge.utils.spec.ts +++ b/src/apps/work/src/lib/utils/challenge.utils.spec.ts @@ -76,6 +76,22 @@ describe('challenge utils', () => { })) .toBe(15.25) }) + + it('returns failed example scorer scores', () => { + expect(getSubmissionExampleScore({ + reviewSummation: [ + { + aggregateScore: -1, + isExample: true, + metadata: { + testStatus: 'FAILED', + testType: 'example', + }, + }, + ], + })) + .toBe(-1) + }) }) describe('getSubmissionTestProgress', () => { @@ -192,6 +208,22 @@ describe('challenge utils', () => { })) .toBeUndefined() }) + + it('returns failed provisional scorer scores when requested', () => { + expect(getSubmissionProvisionalScore({ + reviewSummation: [ + { + aggregateScore: -1, + isProvisional: true, + metadata: { + testProcess: 'provisional', + testStatus: 'FAILED', + }, + }, + ], + }, true)) + .toBe(-1) + }) }) describe('getSubmissionSystemScore', () => { @@ -288,5 +320,21 @@ describe('challenge utils', () => { })) .toBeUndefined() }) + + it('returns failed system scorer scores when requested', () => { + expect(getSubmissionSystemScore({ + reviewSummation: [ + { + aggregateScore: -1, + isFinal: true, + metadata: { + testProcess: 'system', + testStatus: 'FAILED', + }, + }, + ], + }, true)) + .toBe(-1) + }) }) }) diff --git a/src/apps/work/src/lib/utils/challenge.utils.ts b/src/apps/work/src/lib/utils/challenge.utils.ts index c346aac8e..dfccc7731 100644 --- a/src/apps/work/src/lib/utils/challenge.utils.ts +++ b/src/apps/work/src/lib/utils/challenge.utils.ts @@ -106,6 +106,14 @@ function toValidScore(value: unknown): number | undefined { : undefined } +function toFiniteScore(value: unknown): number | undefined { + const score = Number(value) + + return Number.isFinite(score) + ? score + : undefined +} + function getAverageScore(scores: Array): number | undefined { const validScores = scores .filter((score): score is number => score !== undefined) @@ -141,12 +149,14 @@ function getReviewSummationScoreTimestamp(entry: ReviewSummation): number { * Returns the latest valid score from matching Review API summations. * @param reviewSummation Review summations attached to a submission. * @param matcher Predicate that selects the requested Marathon Match score phase. + * @param allowNegativeScore Whether failed Marathon Match scorer scores like -1 should be retained. * @returns Latest valid aggregate score, or `undefined` when no matching score exists. * Used by marathon score display so relative-score rewrites replace stale raw scores. */ function getScoreFromSummation( reviewSummation: ReviewSummation[] | undefined, matcher: (entry: ReviewSummation) => boolean, + allowNegativeScore: boolean = false, ): number | undefined { if (!Array.isArray(reviewSummation) || !reviewSummation.length) { return undefined @@ -156,7 +166,9 @@ function getScoreFromSummation( .map((entry, index) => ({ entry, index, - score: toValidScore(entry.aggregateScore), + score: allowNegativeScore + ? toFiniteScore(entry.aggregateScore) + : toValidScore(entry.aggregateScore), timestamp: getReviewSummationScoreTimestamp(entry), })) .filter(item => item.score !== undefined && matcher(item.entry)) @@ -504,6 +516,7 @@ export function getSubmissionExampleScore( return getScoreFromSummation( submission.reviewSummation, item => getReviewSummationTestProcess(item) === 'example', + true, ) } @@ -514,15 +527,18 @@ export function getProvisionalScore(submission: ScoredSubmissionLike): number { /** * Returns only the provisional marathon score for a submission. * @param submission Submission-like object containing legacy phase scores or Review API summations. + * @param allowNegativeScore Whether failed Marathon Match scorer scores like -1 should be retained. * @returns Provisional score, or `undefined` when provisional scoring has not produced a valid score. * Used by marathon submission score display to prefer latest relative summations over stale raw scores. */ export function getSubmissionProvisionalScore( submission: ScoredSubmissionLike, + allowNegativeScore: boolean = false, ): number | undefined { const provisionalSummationScore = getScoreFromSummation( submission.reviewSummation, item => getReviewSummationTestProcess(item) === 'provisional', + allowNegativeScore, ) if (provisionalSummationScore !== undefined) { return provisionalSummationScore @@ -572,15 +588,18 @@ export function getFinalScore(submission: ScoredSubmissionLike): number { /** * Returns only the system marathon score for a submission. * @param submission Submission-like object containing legacy phase scores or Review API summations. + * @param allowNegativeScore Whether failed Marathon Match scorer scores like -1 should be retained. * @returns System score, or `undefined` when system scoring has not produced a valid score. * Used by marathon submission score display to prefer latest relative summations over stale raw scores. */ export function getSubmissionSystemScore( submission: ScoredSubmissionLike, + allowNegativeScore: boolean = false, ): number | undefined { const systemSummationScore = getScoreFromSummation( submission.reviewSummation, item => getReviewSummationTestProcess(item) === 'system', + allowNegativeScore, ) if (systemSummationScore !== undefined) { return systemSummationScore From 4453e5354d9e70e8d9f567987e9e08b1181828db Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 19 Jun 2026 08:42:22 +1000 Subject: [PATCH 53/73] PM-5394: Left align mobile rating position What was broken Mobile rating comparison modals centered the Position summary after the pyramid graphic was removed. Root cause (if identifiable) The small-screen .positionMetric styles still used justify-content: center, so the remaining position data stayed centered in the summary row. What was changed Changed the small-screen .positionMetric alignment to justify-content: flex-start so the Position data lines up with the summary panel padding on mobile. Any added/updated tests No tests were added or updated for this one-line responsive style change. The existing MemberRatingInfoModal test was run for the affected component. --- .../MemberRatingInfoModal/MemberRatingInfoModal.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss index c3d67d3e9..1354f8d80 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss @@ -106,7 +106,7 @@ flex-wrap: nowrap; gap: $sp-4; grid-column: 1 / -1; - justify-content: center; + justify-content: flex-start; } } From 7c9870e9be4879b82de964f7538983c3db153e60 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 19 Jun 2026 08:51:14 +1000 Subject: [PATCH 54/73] PM-5395: Fix rating modal top percentile display What was broken The profile rating info pop-up could show TOP 0% for highly rated members whose calculated top percentile was positive but below one percent. Root cause The compact profile card already clamps positive sub-one percentile values to 1%, but the modal used a separate formatter that rounded those values to 0. What was changed The rating modal now reuses the compact card percentile formatter so positive sub-one values render as TOP 1% consistently across both surfaces. Any added/updated tests Added a MemberRatingInfoModal regression test covering a positive sub-one percentile value so it renders TOP 1% and never TOP 0%. --- .../MemberRatingInfoModal.spec.tsx | 19 +++++++++++++++++++ .../MemberRatingInfoModal.tsx | 7 ++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx index f9b8cb629..7b06fcb5e 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx @@ -86,4 +86,23 @@ describe('MemberRatingInfoModal', () => { expect(screen.getByText('Where Emily ranks in the distribution')) .toBeInTheDocument() }) + + it('shows a positive sub-one percentile as top one percent', () => { + render( + , + ) + + expect(screen.getByText(/TOP\s+1%/)) + .toBeInTheDocument() + expect(screen.queryByText(/TOP\s+0%/)) + .not + .toBeInTheDocument() + }) }) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx index ed92f88df..98fd62bac 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames' import { getRatingColor, UserProfile, UserStatsDistributionResponse } from '~/libs/core' import { BaseModal } from '~/libs/ui' -import { numberToFixed } from '../../../../lib' +import { formatTopPercentile } from '../MemberRatingCard.utils' import styles from './MemberRatingInfoModal.module.scss' @@ -101,7 +101,8 @@ const chartAxisLabels: Array<{ label: string, value: number }> = [{ /** * Formats percentile values for the rating comparison modal. * - * Used by MemberRatingInfoModal to keep the top percentile text compact. + * Used by MemberRatingInfoModal to keep the top percentile text consistent with + * the compact rating card, including showing positive sub-1% values as 1%. * * @param {number | undefined} percentile - The percentile value calculated from the rating distribution. * @returns {string} A display-ready percentage or `--` when the percentile is unavailable. @@ -109,7 +110,7 @@ const chartAxisLabels: Array<{ label: string, value: number }> = [{ const formatPercentile = (percentile?: number): string => ( percentile === undefined || percentile === 0 ? '--' - : numberToFixed(percentile, 0) + : formatTopPercentile(percentile) ) /** From ebb5af2c75285a798e1037869874115d1bde8580 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 19 Jun 2026 09:12:37 +1000 Subject: [PATCH 55/73] PM-5396: show AI Engineering under Development What was broken AI-tagged Data Science rating paths could appear as standalone profile stat tracks instead of being grouped as the AI Engineering subtrack under Development, while the native Data Science Challenge listing needed to remain visible separately. Root cause (if identifiable) The profile active-track mapper treated all configured DATA_SCIENCE rating paths the same way, so the AI Engineering path was not normalized into the Development hierarchy required by the new profile stats behavior. What was changed Grouped AI Engineering rating path aliases under the Development track as an AI Engineering subtrack, while preserving their original API parent/path metadata for history and distribution calls. Left non-AI custom Data Science rating paths as standalone tracks and kept Data Science Challenge stats under Data Science. Any added/updated tests Updated getActiveTracks tests to cover top-level AI Engineering payloads, DATA_SCIENCE AI Engineering rating paths under Development, and non-AI custom Data Science paths. --- .../src/hooks/useFetchActiveTracks.spec.tsx | 112 ++++++++- .../src/hooks/useFetchActiveTracks.tsx | 229 +++++++++++++++--- 2 files changed, 299 insertions(+), 42 deletions(-) diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx index 91a62f2db..bdf7fd0ad 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx @@ -205,7 +205,7 @@ describe('getActiveTracks', () => { .toEqual(1490) }) - it('keeps AI engineering stats visible when the API returns them', () => { + it('shows top-level AI engineering stats under the Development track', () => { const memberStats = { AI_ENGINEERING: { challenges: 14, @@ -220,25 +220,113 @@ describe('getActiveTracks', () => { challengePoints: 2847, } as UserStats const activeTracks: MemberStatsTrack[] = getActiveTracks(memberStats) - const aiTrack: MemberStatsTrack | undefined = activeTracks.find(track => track.name === 'AI Engineering') + const developmentTrack: MemberStatsTrack | undefined = activeTracks + .find(track => track.name === 'Development') + const aiSubTrack = developmentTrack?.subTracks + .find(track => track.name === 'AI Engineering') - expect(aiTrack) + expect(developmentTrack) .toEqual(expect.objectContaining({ challenges: 14, isActive: true, - percentile: 15, - rating: 101, submissions: 100, })) + expect(aiSubTrack) + .toEqual(expect.objectContaining({ + challenges: 14, + name: 'AI Engineering', + parentTrack: 'AI_ENGINEERING', + path: 'AI_ENGINEERING', + rank: expect.objectContaining({ + overallPercentile: 15, + rating: 101, + }), + submissions: { + submissions: 100, + }, + })) + expect(activeTracks.map(track => track.name)) + .not.toContain('AI Engineering') expect(getMemberChallengePoints(memberStats)) .toBe(2847) }) - it('keeps rated custom data science paths visible as member stats tracks', () => { + it('shows Data Science AI rating paths under Development while keeping original Data Science Challenge', () => { + const activeTracks: MemberStatsTrack[] = getActiveTracks({ + challenges: 6, + DATA_SCIENCE: { + 'AI Engineering': { + challenges: 3, + rank: { + overallPercentile: 12, + rating: 1422, + }, + wins: 1, + }, + Challenge: { + challenges: 3, + rank: { + percentile: 18, + rating: 1380, + }, + wins: 1, + }, + }, + groupId: 1, + handle: 'winterflame', + handleLower: 'winterflame', + userId: 15391415, + wins: 2, + } as unknown as UserStats) + const developmentTrack: MemberStatsTrack | undefined = activeTracks + .find(track => track.name === 'Development') + const dataScienceTrack: MemberStatsTrack | undefined = activeTracks + .find(track => track.name === 'Data Science') + const aiSubTrack = developmentTrack?.subTracks + .find(track => track.name === 'AI Engineering') + const challengeSubTrack = dataScienceTrack?.subTracks + .find(track => track.name === 'Challenge') + + expect(developmentTrack) + .toEqual(expect.objectContaining({ + challenges: 3, + isActive: true, + wins: 1, + })) + expect(aiSubTrack) + .toEqual(expect.objectContaining({ + challenges: 3, + name: 'AI Engineering', + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', + rank: expect.objectContaining({ + overallPercentile: 12, + rating: 1422, + }), + wins: 1, + })) + expect(dataScienceTrack) + .toEqual(expect.objectContaining({ + challenges: 3, + isActive: true, + rating: 1380, + wins: 1, + })) + expect(challengeSubTrack) + .toEqual(expect.objectContaining({ + name: 'Challenge', + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', + })) + expect(activeTracks.map(track => track.name)) + .not.toContain('AI Engineering') + }) + + it('keeps rated custom non-AI data science paths visible as member stats tracks', () => { const activeTracks: MemberStatsTrack[] = getActiveTracks({ challenges: 5, DATA_SCIENCE: { - AI: { + 'Java MySQL': { challenges: 3, rank: { overallPercentile: 12, @@ -257,10 +345,10 @@ describe('getActiveTracks', () => { handleLower: 'winterflame', userId: 15391415, wins: 2, - } as UserStats) - const aiTrack: MemberStatsTrack | undefined = activeTracks.find(track => track.name === 'AI') + } as unknown as UserStats) + const javaMySQLTrack: MemberStatsTrack | undefined = activeTracks.find(track => track.name === 'Java MySQL') - expect(aiTrack) + expect(javaMySQLTrack) .toEqual(expect.objectContaining({ challenges: 3, isActive: true, @@ -269,10 +357,10 @@ describe('getActiveTracks', () => { rating: 1422, wins: 1, })) - expect(aiTrack?.subTracks) + expect(javaMySQLTrack?.subTracks) .toEqual([ expect.objectContaining({ - name: 'AI', + name: 'Java MySQL', parentTrack: 'DATA_SCIENCE', path: 'DATA_SCIENCE', }), diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx index b1ceff35e..5839a2e18 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx @@ -29,6 +29,14 @@ const nativeDataScienceStatsKeys = new Set([ 'wins', ]) +const AI_ENGINEERING_DISPLAY_NAME = 'AI Engineering' + +const aiEngineeringRatingPathNames = new Set([ + 'AI', + 'AI_ENGINEER', + 'AI_ENGINEERING', +]) + /** * The structure of a track for a member. */ @@ -112,6 +120,28 @@ const getDataScienceSummarySubTrack = (subTracks: MemberStats[]): MemberStats | ['desc', 'desc', 'desc'], )[0] +/** + * Normalizes a track or rating path name for alias comparison. + * + * @param {string | undefined} value - Raw track, subtrack, or configured rating path name. + * @returns {string} Uppercase underscore-delimited token. + */ +const normalizeTrackToken = (value?: string): string => ( + value?.trim() + .toUpperCase() + .replace(/[\s-]+/g, '_') ?? '' +) + +/** + * Checks whether a configured rating path is one of the AI Engineering aliases. + * + * @param {string | undefined} ratingPathName - DATA_SCIENCE rating path key. + * @returns {boolean} Whether the path should render as Development > AI Engineering. + */ +const isAIEngineeringRatingPathName = (ratingPathName?: string): boolean => ( + aiEngineeringRatingPathNames.has(normalizeTrackToken(ratingPathName)) +) + /** * Attach parent track metadata to legacy design/develop subtracks and index them by name. * @@ -240,47 +270,180 @@ const enhanceDesignTrackData = (trackData: MemberStatsTrack): MemberStatsTrack = } /** - * Builds the AI Engineering aggregate stats row from a top-level API payload. + * Converts a top-level AI payload into source subtracks for aggregation. * - * @param {UserStats | undefined} memberStats - The raw stats payload for the user. - * @returns {MemberStatsTrack} Aggregated AI Engineering stats for the member stats UI. + * @param {MemberStatsGroup | undefined} aiStats - Raw top-level AI stats payload. + * @returns {MemberStats[]} Source subtracks used to aggregate an AI Engineering row. */ -const buildAIEngineeringTrackData = (memberStats?: UserStats): MemberStatsTrack => { - const aiStats = getAIEngineeringStats(memberStats) - const subTracks: MemberStats[] = aiStats?.subTracks?.length ? ( - Object.values(mapSubTracksByName('AI_ENGINEERING', aiStats.subTracks)) - ) : (aiStats ? [{ +const getTopLevelAIEngineeringSourceSubTracks = (aiStats?: MemberStatsGroup): MemberStats[] => { + if (!aiStats) { + return [] + } + + if (aiStats.subTracks?.length) { + return Object.values(mapSubTracksByName('AI_ENGINEERING', aiStats.subTracks)) + } + + return [{ ...(aiStats as MemberStats), - name: aiStats.name ?? 'AI_ENGINEERING', + name: AI_ENGINEERING_DISPLAY_NAME, parentTrack: 'AI_ENGINEERING', path: 'AI_ENGINEERING', - }] : []) + }] +} + +/** + * Checks whether a top-level AI payload has visible profile activity. + * + * @param {MemberStatsTrack} trackData - Aggregated AI source subtrack data. + * @param {number | undefined} rating - Current AI Engineering rating. + * @param {number | undefined} challenges - Current AI Engineering challenge count. + * @param {number | undefined} submissions - Current AI Engineering submission count. + * @param {number | undefined} wins - Current AI Engineering win count. + * @returns {boolean} Whether the payload should create a Development subtrack. + */ +const hasTopLevelAIEngineeringActivity = ( + trackData: MemberStatsTrack, + rating?: number, + challenges?: number, + submissions?: number, + wins?: number, +): boolean => ( + trackData.isActive + || !!rating + || !!challenges + || !!submissions + || !!wins +) - const trackData = buildTrackData('AI Engineering', subTracks) +/** + * Builds the rank object for a Development AI Engineering subtrack. + * + * @param {MemberStatsGroup} aiStats - Raw top-level AI stats payload. + * @param {number | undefined} rating - Current AI Engineering rating. + * @param {number | undefined} percentile - Current AI Engineering percentile. + * @returns {MemberStats['rank']} Rank fields for the display subtrack. + */ +const buildAIEngineeringRank = ( + aiStats: MemberStatsGroup, + rating?: number, + percentile?: number, +): MemberStats['rank'] => ({ + ...((aiStats as MemberStats).rank ?? {}), + ...(rating === undefined ? {} : { rating }), + ...(percentile === undefined ? {} : { overallPercentile: percentile }), +}) + +/** + * Builds the submissions field for a Development AI Engineering subtrack. + * + * @param {MemberStatsGroup} aiStats - Raw top-level AI stats payload. + * @param {number | undefined} submissions - Aggregated submission count. + * @returns {MemberStats['submissions']} Submission stats for the display subtrack. + */ +const buildAIEngineeringSubmissions = ( + aiStats: MemberStatsGroup, + submissions?: number, +): MemberStats['submissions'] => ( + submissions === undefined + ? (aiStats as MemberStats).submissions + : { submissions } +) + +/** + * Builds a Development subtrack from a top-level AI Engineering API payload. + * + * @param {UserStats | undefined} memberStats - The raw stats payload for the user. + * @returns {MemberStats | undefined} AI Engineering subtrack data when active stats exist. + */ +const buildTopLevelAIEngineeringSubTrack = (memberStats?: UserStats): MemberStats | undefined => { + const aiStats = getAIEngineeringStats(memberStats) + + if (!aiStats) { + return undefined + } + + const subTracks = getTopLevelAIEngineeringSourceSubTracks(aiStats) + const trackData = buildTrackData(AI_ENGINEERING_DISPLAY_NAME, subTracks) const submissions = getSubTrackSubmissionCount(aiStats as MemberStats | undefined) ?? trackData.submissions - const challenges = getFiniteNumber(aiStats?.challenges) ?? trackData.challenges + const challenges = getFiniteNumber(aiStats?.challenges) ?? trackData.challenges ?? 0 const rating = getFiniteNumber(aiStats?.rank?.rating) const wins = getFiniteNumber(aiStats?.wins) ?? trackData.wins + const percentile = getFiniteNumber(aiStats?.rank?.overallPercentile) + ?? getFiniteNumber(aiStats?.rank?.percentile) + + if (!hasTopLevelAIEngineeringActivity(trackData, rating, challenges, submissions, wins)) { + return undefined + } return { - ...trackData, - challengePoints: getFiniteNumber(aiStats?.challengePoints), + ...(aiStats as MemberStats), challenges, - isActive: trackData.isActive - || !!rating - || !!challenges - || !!submissions - || !!wins, - name: 'AI Engineering', - order: 2, - percentile: getFiniteNumber(aiStats?.rank?.overallPercentile) ?? getFiniteNumber(aiStats?.rank?.percentile), - rating, - submissions, - subTracks, + name: AI_ENGINEERING_DISPLAY_NAME, + parentTrack: 'AI_ENGINEERING', + path: 'AI_ENGINEERING', + rank: buildAIEngineeringRank(aiStats, rating, percentile), + submissions: buildAIEngineeringSubmissions(aiStats, submissions), wins, } } +/** + * Builds a Development subtrack from a DATA_SCIENCE AI Engineering rating path. + * + * The API can return configured AI Engineering rows under DATA_SCIENCE while + * the profile hierarchy displays that rating path under Development. The + * returned subtrack keeps DATA_SCIENCE metadata so history and distribution + * calls still use the stored API dimension. + * + * @param {UserStats | undefined} memberStats - The raw stats payload for the user. + * @returns {MemberStats | undefined} AI Engineering subtrack data when the path is rated. + */ +const buildDataScienceAIEngineeringSubTrack = (memberStats?: UserStats): MemberStats | undefined => { + const dataScienceStats = memberStats?.DATA_SCIENCE + + if (!dataScienceStats) { + return undefined + } + + const candidates = Object.entries(dataScienceStats) + .reduce((subTracks: MemberStats[], [ratingPathName, ratingPathStats]) => { + if ( + nativeDataScienceStatsKeys.has(ratingPathName) + || !isAIEngineeringRatingPathName(ratingPathName) + || !isDataScienceRatingPathStats(ratingPathStats) + ) { + return subTracks + } + + subTracks.push({ + ...(ratingPathStats as MemberStats), + name: AI_ENGINEERING_DISPLAY_NAME, + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', + }) + + return subTracks + }, []) + + return getDataScienceSummarySubTrack(candidates) +} + +/** + * Returns the AI Engineering subtrack that should be grouped under Development. + * + * DATA_SCIENCE rating-path rows are preferred because they retain the native + * history/distribution path for existing API payloads. Top-level AI payloads + * are still supported for compatibility. + * + * @param {UserStats | undefined} memberStats - The raw stats payload for the user. + * @returns {MemberStats | undefined} Display-ready Development subtrack. + */ +const getAIEngineeringDevelopmentSubTrack = (memberStats?: UserStats): MemberStats | undefined => ( + buildDataScienceAIEngineeringSubTrack(memberStats) + ?? buildTopLevelAIEngineeringSubTrack(memberStats) +) + /** * Builds an active track from a configured DATA_SCIENCE rating path. * @@ -334,6 +497,7 @@ const getDataScienceRatingPathTrackData = (memberStats?: UserStats): MemberStats .reduce((ratingPathTracks: MemberStatsTrack[], [ratingPathName, ratingPathStats]) => { if ( nativeDataScienceStatsKeys.has(ratingPathName) + || isAIEngineeringRatingPathName(ratingPathName) || !isDataScienceRatingPathStats(ratingPathStats) ) { return ratingPathTracks @@ -396,9 +560,6 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => ) // Build aggregated stats for Design, Development, Testing, and Competitive Programming tracks - // AI Engineering - const aiEngineeringTrackStats: MemberStatsTrack = buildAIEngineeringTrackData(memberStats) - // Design const designTrackStats: MemberStatsTrack = ( enhanceDesignTrackData( @@ -407,10 +568,19 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => ) // Development + const aiEngineeringDevelopmentSubTrack = getAIEngineeringDevelopmentSubTrack(memberStats) + const developSubTrackValues = Object.values(developSubTracks) + const hasDevelopmentAIEngineeringSubTrack = developSubTrackValues + .some(subTrack => isAIEngineeringRatingPathName(subTrack.name)) const developTrackStats: MemberStatsTrack = ( buildTrackData( 'Development', - Object.values(developSubTracks) + [ + ...developSubTrackValues, + ...(hasDevelopmentAIEngineeringSubTrack || !aiEngineeringDevelopmentSubTrack + ? [] + : [aiEngineeringDevelopmentSubTrack]), + ] .filter(subTrack => !isTestingSubTrack(subTrack)), ) ) @@ -464,7 +634,6 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => // Order and filter active tracks based on wins and submissions return orderBy(filter([ - aiEngineeringTrackStats, dsTrackStats, cpTrackStats, designTrackStats, From c75f11bf40e5af59cef083e93a5e810f835f99e4 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 19 Jun 2026 09:22:55 +1000 Subject: [PATCH 56/73] PM-5398: Fix member stats typography What was broken The member stats card did not match the PM-5398 typography requirements: the title, track labels, stat values, stat captions, and footer note rendered at the wrong sizes. Root cause The MemberStatsBlock SCSS overrode the shared typography classes with larger desktop sizes and smaller container-query sizes that differed from the required PM-5398 values. What was changed Updated the MemberStatsBlock stylesheet so the title is 24px, track names are 16px, stat values are 26px, stat captions are 11px, and the footer note is 16px across the member stats layout. Any added/updated tests Added a MemberStatsBlock typography regression assertion that checks the requested font-size rules stay in the stylesheet. --- .../MemberStatsBlock.module.scss | 38 +++++++++---------- .../MemberStatsBlock.spec.tsx | 18 +++++++++ 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss index 5e7cf1236..af1c560fa 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss @@ -87,7 +87,7 @@ } :global(.body-large-bold) { - font-size: 26px; + font-size: 24px; line-height: 32px; text-align: center; } @@ -103,8 +103,8 @@ color: rgba($tc-white, 0.88); :global(.body-main) { - font-size: 18px; - line-height: 28px; + font-size: 16px; + line-height: 24px; } } @@ -177,15 +177,15 @@ .count { font-family: $font-barlow-condensed; font-weight: $font-weight-medium; - font-size: 34px; - line-height: 36px; + font-size: 26px; + line-height: 28px; } .label { font-family: $font-roboto; font-weight: $font-weight-medium; - font-size: 14px; - line-height: 18px; + font-size: 11px; + line-height: 14px; color: rgba($tc-white, 0.82); } } @@ -204,8 +204,8 @@ padding-right: $sp-2; overflow: hidden; font-family: $font-roboto; - font-size: 18px; - line-height: 24px; + font-size: 16px; + line-height: 22px; text-overflow: ellipsis; white-space: nowrap; } @@ -215,8 +215,8 @@ padding: $sp-4; :global(.body-large-bold) { - font-size: 18px; - line-height: 24px; + font-size: 24px; + line-height: 32px; } } @@ -226,8 +226,8 @@ .footerNote { :global(.body-main) { - font-size: 12px; - line-height: 18px; + font-size: 16px; + line-height: 24px; } } @@ -244,19 +244,19 @@ min-width: 42px; .count { - font-size: 22px; - line-height: 24px; + font-size: 26px; + line-height: 28px; } .label { - font-size: 10px; - line-height: 12px; + font-size: 11px; + line-height: 14px; } } .trackName { - font-size: 12px; - line-height: 16px; + font-size: 16px; + line-height: 22px; } .icon { diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.spec.tsx b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.spec.tsx index e8c4a00c7..53ac3af09 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.spec.tsx +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.spec.tsx @@ -1,5 +1,6 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ import '@testing-library/jest-dom' +import { readFileSync } from 'fs' import type { PropsWithChildren } from 'react' import { render, screen, within } from '@testing-library/react' @@ -7,6 +8,8 @@ import type { UserProfile } from '~/libs/core' import { MemberChallengePointsBar } from './MemberStatsBlock' +const memberStatsBlockStyles = readFileSync(`${__dirname}/MemberStatsBlock.module.scss`, 'utf8') + jest.mock('~/libs/core', () => ({ getRatingColor: jest.fn(() => '#000000'), useMemberStats: jest.fn(), @@ -75,3 +78,18 @@ describe('MemberChallengePointsBar', () => { .toHaveClass('icon-sm') }) }) + +describe('MemberStatsBlock typography styles', () => { + it('uses the PM-5398 font sizes for the member stats section', () => { + expect(memberStatsBlockStyles) + .toMatch(/:global\(\.body-large-bold\) \{\s*font-size: 24px;/) + expect(memberStatsBlockStyles) + .toMatch(/:global\(\.body-main\) \{\s*font-size: 16px;/) + expect(memberStatsBlockStyles) + .toMatch(/\.count \{[\s\S]*?font-size: 26px;/) + expect(memberStatsBlockStyles) + .toMatch(/\.label \{[\s\S]*?font-size: 11px;/) + expect(memberStatsBlockStyles) + .toMatch(/\.trackName \{[\s\S]*?font-size: 16px;/) + }) +}) From 67ff3fd1b29ab3e7dfd3ca6bb37ee1a26741f5f9 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 19 Jun 2026 09:29:52 +1000 Subject: [PATCH 57/73] Updates for PM-5330 --- src/apps/engagements/src/lib/utils/terms.utils.spec.ts | 2 +- src/config/environments/default.env.ts | 2 +- src/config/environments/prod.env.ts | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/apps/engagements/src/lib/utils/terms.utils.spec.ts b/src/apps/engagements/src/lib/utils/terms.utils.spec.ts index f1ab1c09d..a8c36079b 100644 --- a/src/apps/engagements/src/lib/utils/terms.utils.spec.ts +++ b/src/apps/engagements/src/lib/utils/terms.utils.spec.ts @@ -8,7 +8,7 @@ import { describe('engagement terms utils', () => { const OLD_TERMS_ID = '317cd8f9-d66c-4f2a-8774-63c612d99cd4' const NEW_TERMS_ID = '0a507fb7-3fe0-402b-b121-1a24af4a9cf1' - const NEW_NDA_TEMPLATE_ID = '8b101e82-87c0-42c9-8440-d922749c4076' + const NEW_NDA_TEMPLATE_ID = '400b989d-1c75-4889-b6f6-421e1f924709' const TERMS_URL = `https://www.topcoder-dev.com/challenges/terms/detail/${OLD_TERMS_ID}` it('extracts the terms id from a terms detail URL', () => { diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index 1e281f2e8..5b39ca67d 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -223,7 +223,7 @@ export const TERMS_URL export const NDA_TERMS_URL = 'https://www.topcoder-dev.com/challenges/terms/detail/e5811a7b-43d1-407a-a064-69e5015b4900' export const DEFAULT_STANDARD_TERMS_UUID = '0a507fb7-3fe0-402b-b121-1a24af4a9cf1' -export const NDA_DOCUSIGN_TEMPLATE_ID = '8b101e82-87c0-42c9-8440-d922749c4076' +export const NDA_DOCUSIGN_TEMPLATE_ID = '400b989d-1c75-4889-b6f6-421e1f924709' export const PRIVACY_POLICY_URL = `${TOPCODER_URL}/policy` diff --git a/src/config/environments/prod.env.ts b/src/config/environments/prod.env.ts index e63685ef0..5127275df 100644 --- a/src/config/environments/prod.env.ts +++ b/src/config/environments/prod.env.ts @@ -5,7 +5,10 @@ export * from './default.env' export const TERMS_URL = 'https://www.topcoder.com/challenges/terms/detail/564a981e-6840-4a5c-894e-d5ad22e9cd6f' export const NDA_TERMS_URL = 'https://www.topcoder.com/challenges/terms/detail/c41e90e5-4d0e-4811-bd09-38ff72674490' -export const NDA_DOCUSIGN_TEMPLATE_ID = getReactEnv('NDA_DOCUSIGN_TEMPLATE_ID', undefined) +export const NDA_DOCUSIGN_TEMPLATE_ID = getReactEnv( + 'NDA_DOCUSIGN_TEMPLATE_ID', + '8b101e82-87c0-42c9-8440-d922749c4076', +) export const VANILLA_FORUM = { V2_URL: 'https://vanilla.topcoder.com/api/v2', From 8a6fc969097c7426f4666a376849afe788f022fd Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 19 Jun 2026 09:34:17 +1000 Subject: [PATCH 58/73] PM-5401: Keep member stats text readable with TCO banner What was broken Member stats reused the same narrow-card breakpoint for both the one-column layout and compact mobile typography. When the TCO trip-winner banner made the stats card narrow on desktop, the card switched to noticeably smaller text. Root cause The compact typography rules were tied to the 520px layout breakpoint instead of a narrower phone-width breakpoint. What was changed Moved the compact member-stats typography, icon, and padding container query from 520px to 420px. The stats list can still switch to one column at 520px, but desktop-sized text remains at the side-by-side TCO card width. Any added/updated tests No tests were added because this is a CSS container-query adjustment and the repo has no browser visual test harness for this card. Validated the affected profiles tests, lint, and production build. The full repo non-watch test command was also run and failed in unrelated work, engagements, and wallet suites on existing baseline issues. --- .../MemberStatsBlock/MemberStatsBlock.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss index 5e7cf1236..a0ad05128 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss @@ -210,7 +210,7 @@ white-space: nowrap; } -@container (max-width: 520px) { +@container (max-width: 420px) { .container { padding: $sp-4; From 8f0c4472aed5efa5d55cbaefe64a9fdc2fdc9970 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 19 Jun 2026 09:42:57 +1000 Subject: [PATCH 59/73] PM-5402: Stack high rating marker label What was broken High member ratings near the right edge of the rating distribution chart kept the avatar and rating inline, which could push the numeric rating partly outside the chart. Root cause The marker badge always translated the avatar and rating to the right of the marker, leaving no alternate layout for right-edge positions. What was changed Added a right-edge marker layout that stacks the rating below the avatar when the marker is at least 90% across the chart, keeping the value visible while preserving the existing inline layout elsewhere. Any added/updated tests Added a MemberRatingInfoModal regression test for a 3664 rating that verifies the stacked marker layout is applied. --- .../MemberRatingInfoModal.module.scss | 12 ++++++++++++ .../MemberRatingInfoModal.spec.tsx | 16 ++++++++++++++++ .../MemberRatingInfoModal.tsx | 9 ++++++++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss index 1354f8d80..2c27fc0ec 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss @@ -225,6 +225,18 @@ z-index: 1; } +.memberMarkerStacked { + &::after { + top: $sp-14; + } + + .markerBadge { + flex-direction: column; + gap: $sp-1; + transform: translateX(0); + } +} + .markerAvatar { align-items: center; background: $tc-white; diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx index 7b06fcb5e..05fe3c33a 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx @@ -105,4 +105,20 @@ describe('MemberRatingInfoModal', () => { .not .toBeInTheDocument() }) + + it('stacks the marker rating for high ratings near the right edge of the chart', () => { + render( + , + ) + + expect(screen.getByTestId('rating-member-marker')) + .toHaveClass('memberMarkerStacked') + }) }) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx index 98fd62bac..93facdf52 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx @@ -98,6 +98,8 @@ const chartAxisLabels: Array<{ label: string, value: number }> = [{ value: 2200, }] +const stackedMarkerPositionThreshold = 90 + /** * Formats percentile values for the rating comparison modal. * @@ -269,6 +271,7 @@ const MemberRatingInfoModal: FC = (props: MemberRati const markerPosition: number = props.rating !== undefined ? getChartPosition(props.rating, distributionRanges) : 0 + const shouldStackMarkerRating: boolean = markerPosition >= stackedMarkerPositionThreshold const percentileLabel: string = formatPercentile(props.percentile) return ( @@ -354,7 +357,11 @@ const MemberRatingInfoModal: FC = (props: MemberRati {props.rating !== undefined && (
    From 1875a22f43c5fea1151bb6203bfffbb579bb3e62 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 19 Jun 2026 09:51:02 +1000 Subject: [PATCH 60/73] PM-5403: Reduce profile section spacing What was broken The profile Experience and Education and Certifications cards had a larger vertical gap than the updated design. Root cause The Experience/Education wrapper added a 40px grid gap on top of the existing section card margin. What was changed Removed the extra grid gap from the profile Experience/Education wrapper so the stack relies on the standard section spacing. Any added/updated tests No tests were added because this is a CSS-only spacing change. Ran the existing profile member tests, lint, and build. The full non-watch test suite was also run and currently fails in unrelated wallet-admin and work specs outside this change. --- .../member-profile/page-layout/ProfilePageLayout.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss b/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss index 8cfb8bd1c..c9171fef8 100644 --- a/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss +++ b/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss @@ -160,7 +160,7 @@ .expirenceWrap { display: grid; grid-template-columns: 1fr; - gap: 40px; + gap: 0; @include ltelg { grid-template-columns: 1fr; From a3a51a38e9706087d3f24c485532399e78195b86 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 19 Jun 2026 10:11:51 +1000 Subject: [PATCH 61/73] PM-5397: Show AI Engineering challenge history What was broken AI Engineering could show challenge counts on the profile stats page while the Challenges Details view was empty. Root cause The profile history lookup used only the displayed subtrack name and current API path. After AI Engineering was normalized under Development, the history response could still be keyed by Data Science AI aliases such as DATA_SCIENCE.AI, so no challenge history rows were found. What was changed Added AI Engineering history alias fallback logic that keeps the exact history lookup first, then checks the known AI Engineering aliases across Data Science, Development, and top-level AI paths. Any added/updated tests Added a useTrackHistory regression test for a Development AI Engineering subtrack reading challenge history from the DATA_SCIENCE.AI alias. --- .../src/hooks/useTrackHistory.spec.tsx | 37 +++++ .../profiles/src/hooks/useTrackHistory.tsx | 128 ++++++++++++++++-- 2 files changed, 157 insertions(+), 8 deletions(-) diff --git a/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx b/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx index 2eb16f19a..9bd01e75e 100644 --- a/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx +++ b/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx @@ -79,4 +79,41 @@ describe('getTrackHistoryFromStats', () => { }), ]) }) + + it('reads AI Engineering challenge history from Data Science aliases', () => { + const trackData = { + name: 'AI Engineering', + parentTrack: 'DEVELOP', + path: 'DEVELOP.subTracks', + } as MemberStats + const history = getTrackHistoryFromStats({ + DATA_SCIENCE: { + AI: { + history: [ + { + challengeId: 'ai-challenge', + challengeName: 'AI Challenge', + newRating: 840, + ratingDate: 1781237773026, + }, + ], + }, + }, + DEVELOP: { + subTracks: [], + }, + groupId: 10, + handle: 'aimember', + handleLower: 'aimember', + userId: 89770399, + } as unknown as UserStatsHistory, trackData) + + expect(history) + .toEqual([ + expect.objectContaining({ + challengeId: 'ai-challenge', + newRating: 840, + }), + ]) + }) }) diff --git a/src/apps/profiles/src/hooks/useTrackHistory.tsx b/src/apps/profiles/src/hooks/useTrackHistory.tsx index 388882bd7..9e87c031e 100644 --- a/src/apps/profiles/src/hooks/useTrackHistory.tsx +++ b/src/apps/profiles/src/hooks/useTrackHistory.tsx @@ -2,6 +2,98 @@ import { find, get } from 'lodash' import { MemberStats, StatsHistory, UserStatsHistory, useStatsHistory } from '~/libs/core' +const AI_ENGINEERING_HISTORY_NAMES = [ + 'AI Engineering', + 'AI', + 'AI_ENGINEER', + 'AI_ENGINEERING', +] + +const AI_ENGINEERING_TRACK_TOKENS = new Set([ + 'AI', + 'AI_ENGINEER', + 'AI_ENGINEERING', +]) + +const AI_ENGINEERING_HISTORY_PATHS = [ + 'DATA_SCIENCE', + 'DEVELOP.subTracks', + 'AI_ENGINEERING', + 'AI', + 'AI_ENGINEER', +] + +/** + * Normalizes a track or rating path name for alias comparison. + * + * @param {string | undefined} value - Raw track, subtrack, or configured rating path name. + * @returns {string} Uppercase underscore-delimited token. + */ +const normalizeTrackToken = (value?: string): string => ( + value?.trim() + .toUpperCase() + .replace(/[\s-]+/g, '_') ?? '' +) + +/** + * Checks whether a displayed stats subtrack represents AI Engineering. + * + * @param {MemberStats} trackData - Displayed subtrack data from the member stats payload. + * @returns {boolean} Whether the subtrack should use AI Engineering history aliases. + */ +const isAIEngineeringTrackData = (trackData: MemberStats): boolean => ( + AI_ENGINEERING_TRACK_TOKENS.has(normalizeTrackToken(trackData.name)) + || AI_ENGINEERING_TRACK_TOKENS.has(normalizeTrackToken(trackData.parentTrack)) +) + +/** + * Returns a history array only when it contains challenge rows. + * + * @param {StatsHistory[] | undefined} history - Candidate history rows. + * @returns {StatsHistory[] | undefined} Non-empty history rows, otherwise undefined. + */ +const getNonEmptyHistory = (history?: StatsHistory[]): StatsHistory[] | undefined => ( + Array.isArray(history) && history.length > 0 ? history : undefined +) + +/** + * Reads the first non-empty history array from keyed or subtrack-based paths. + * + * @param {UserStatsHistory | undefined} statsHistory - Raw stats-history payload. + * @param {string[]} paths - Candidate API paths that may contain history. + * @param {string[]} trackNames - Candidate subtrack or rating path names. + * @returns {StatsHistory[] | undefined} First matching history rows. + */ +const getFirstMatchingHistory = ( + statsHistory: UserStatsHistory | undefined, + paths: string[], + trackNames: string[], +): StatsHistory[] | undefined => { + for (const path of paths) { + const subTracks = get(statsHistory, path) + + if (Array.isArray(subTracks)) { + const history = getNonEmptyHistory( + find(subTracks, subTrack => trackNames.includes(subTrack.name))?.history, + ) + + if (history) { + return history + } + } + + for (const trackName of trackNames) { + const history = getNonEmptyHistory(get(statsHistory, `${path}.${trackName}.history`)) + + if (history) { + return history + } + } + } + + return undefined +} + /** * Fetches the default history data for a track using its API path and name. * @@ -12,15 +104,31 @@ import { MemberStats, StatsHistory, UserStatsHistory, useStatsHistory } from '~/ const getDefaultTrackHistory = ( statsHistory: UserStatsHistory | undefined, trackData: MemberStats, -): StatsHistory[] => get( - find(get(statsHistory, `${trackData.path}`, []), { name: trackData.name }), - 'history', +): StatsHistory[] => ( + getFirstMatchingHistory(statsHistory, [trackData.path ?? ''], [trackData.name]) ?? [] ) - // marathon match and some unified stats dimensions have a keyed history structure - || get( + +/** + * Reads AI Engineering history across the API aliases used before and after + * the profile hierarchy moved AI Engineering under Development. + * + * @param {UserStatsHistory | undefined} statsHistory - Raw stats-history payload. + * @param {MemberStats} trackData - Displayed AI Engineering subtrack data. + * @returns {StatsHistory[]} AI Engineering challenge history rows. + */ +const getAIEngineeringTrackHistory = ( + statsHistory: UserStatsHistory | undefined, + trackData: MemberStats, +): StatsHistory[] => ( + getFirstMatchingHistory( statsHistory, - `${trackData.path}.${trackData.name}.history`, - ) || [] + [ + trackData.path, + ...AI_ENGINEERING_HISTORY_PATHS, + ].filter((path): path is string => !!path), + AI_ENGINEERING_HISTORY_NAMES, + ) ?? [] +) /** * Extracts the history rows for a displayed track. @@ -37,7 +145,11 @@ export const getTrackHistoryFromStats = ( return [] } - return getDefaultTrackHistory(statsHistory, trackData) + const defaultHistory = getDefaultTrackHistory(statsHistory, trackData) + + return defaultHistory.length > 0 || !isAIEngineeringTrackData(trackData) + ? defaultHistory + : getAIEngineeringTrackHistory(statsHistory, trackData) } /** From cfe1c48a02e461bfedec9a3f087d4329c4cf2ff6 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 19 Jun 2026 10:33:59 +1000 Subject: [PATCH 62/73] PM-5384: Tune profile photo name spacing What was broken The previous PM-5384 fix moved the desktop About Me greeting closer to the profile picture, but QA reported that the result was now a bit too close and needed more breathing room. Root cause The prior desktop left-column offset changed from -60px to -140px, which corrected the original oversized gap but slightly overcorrected the spacing under the avatar. What was changed Reduced the desktop left profile column offset from -140px to -120px so the greeting sits lower while still remaining much closer to the profile picture than the original layout. Tablet and mobile offsets remain unchanged. Any added/updated tests No tests were added because this is a CSS-only spacing adjustment. Validation included lint, production build, a related-test lookup for the changed stylesheet, and the existing profile-area test suite. --- .../member-profile/page-layout/ProfilePageLayout.module.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss b/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss index c9171fef8..85e4f51d0 100644 --- a/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss +++ b/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss @@ -118,7 +118,7 @@ .profileInfoLeft { display: flex; flex-direction: column; - margin-top: -140px; + margin-top: -120px; @include ltelg { margin-top: 0; From aa28463d10264efd42403649ec9917da22485f6d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 19 Jun 2026 11:24:10 +1000 Subject: [PATCH 63/73] PM-5402: Stack high rating marker sooner What was broken The prior PM-5402 fix only stacked the rating label after the marker reached 90% of the chart. QA still saw the tourist rating rendered inline and clipped at the right edge. Root cause The inline avatar and score need horizontal room to the right of the marker, so a percentage threshold that close to the edge was too conservative. The previous regression test used a shortened distribution that clamped 3664 to the chart end, so it did not cover a high score that sits before the final tenth of the rendered chart. What was changed Lowered the right-edge stacking threshold to 80% so high ratings switch to the stacked avatar-over-score layout before the inline badge can run out of space. Any added/updated tests Updated the MemberRatingInfoModal regression fixture so the 3664 score is tested against a wider distribution range and still applies the stacked marker layout. --- .../MemberRatingInfoModal.spec.tsx | 13 ++++++++++++- .../MemberRatingInfoModal/MemberRatingInfoModal.tsx | 4 +++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx index 05fe3c33a..b7ca8ab03 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx @@ -28,6 +28,17 @@ const ratingDistribution = { updatedBy: 'test', } +const wideRatingDistribution = { + ...ratingDistribution, + distribution: { + ratingRange0To899: 10, + ratingRange900To1199: 20, + ratingRange1200To1499: 30, + ratingRange1500To2199: 40, + ratingRange2200To4499: 1, + }, +} + jest.mock('~/libs/core', () => ({ getRatingColor: jest.fn(() => '#616BD5'), }), { @@ -95,7 +106,7 @@ describe('MemberRatingInfoModal', () => { percentile={0.4} profile={baseProfile} rating={3664} - ratingDistribution={ratingDistribution} + ratingDistribution={wideRatingDistribution} />, ) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx index 93facdf52..416718cb7 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx @@ -98,7 +98,9 @@ const chartAxisLabels: Array<{ label: string, value: number }> = [{ value: 2200, }] -const stackedMarkerPositionThreshold = 90 +// The inline avatar and score need room to the right of the marker, so stack +// before the marker reaches the final segment of the chart. +const stackedMarkerPositionThreshold = 80 /** * Formats percentile values for the rating comparison modal. From 4b74cf50e1f1466d7a11c3fec77028a13531b2c9 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 19 Jun 2026 11:42:25 +1000 Subject: [PATCH 64/73] PM-5401: Align member stats chevrons What was broken The previous PM-5401 fix kept the member stats typography readable for trip-winner layouts, but QA reported that the chevron on the right side of each stats row still did not align consistently. Root cause The right-side track details group had a fixed minimum width, but its contents were left-aligned inside that space. Rows without winner or rating icons left unused space after the chevron, so the chevron landed farther left than rows with icons or wider values. What was changed Right-aligned the contents of the track details group so each row's chevron sits on the same right edge while preserving the existing row structure and spacing. Any added/updated tests Updated the MemberStatsBlock SCSS test to assert that track details are right-aligned for consistent chevron placement. --- .../MemberStatsBlock/MemberStatsBlock.module.scss | 1 + .../MemberStatsBlock/MemberStatsBlock.spec.tsx | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss index 8927081cf..b691dda8d 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.module.scss @@ -151,6 +151,7 @@ .trackDetails { display: flex; align-items: center; + justify-content: flex-end; min-width: 136px; > svg { diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.spec.tsx b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.spec.tsx index 53ac3af09..ae736e85e 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.spec.tsx +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.spec.tsx @@ -92,4 +92,9 @@ describe('MemberStatsBlock typography styles', () => { expect(memberStatsBlockStyles) .toMatch(/\.trackName \{[\s\S]*?font-size: 16px;/) }) + + it('right-aligns track details so member stats chevrons line up', () => { + expect(memberStatsBlockStyles) + .toMatch(/\.trackDetails \{[\s\S]*?justify-content: flex-end;/) + }) }) From 9ad5d422f61032e0bdfb8b1c9b4604b5334fbc92 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 19 Jun 2026 13:32:20 +0300 Subject: [PATCH 65/73] PM-5365 - fix lock message --- .../src/lib/components/AiReviewsTable/AiReviewsTable.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx index 384489ada..a78b49b35 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -140,15 +140,12 @@ function resolveHandle( * Pass false for reviewer role so author identity is hidden. * Defaults to true. * @param resourceMemberIdMapping - Map of memberId → BackendResource used to resolve handles. - * @param submissionLocked - When true, labels the reason as "Locked Reason"; - * otherwise labels it as "Unlock Reason". */ function buildDecisionNotes( escalations?: AiReviewDecisionEscalation[], reason?: string | null, showAuthor: boolean = true, resourceMemberIdMapping: Record = {}, - submissionLocked: boolean = false, ): string[] { const parts: string[] = [] @@ -172,8 +169,7 @@ function buildDecisionNotes( }) if (reason) { - const reasonLabel: string = submissionLocked ? 'Locked Reason' : 'Unlock Reason' - parts.push(`${reasonLabel}: ${reason}`) + parts.push(`Locked Reason: ${reason}`) } return parts @@ -392,7 +388,6 @@ const AiReviewsTable: FC = props => { currentDecision.reason, canSeeAuthor, resourceMemberIdMapping, - currentDecision.submissionLocked, ) }, [canSeeAuthor, currentDecision, hasSubmitterRole, resourceMemberIdMapping]) From 5be81d2df764fbb43d5727f89b6d7acd25648109 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 22 Jun 2026 13:33:38 +1000 Subject: [PATCH 66/73] PM-5406: Close rating tooltip with info modal What was broken The profile rating percentile tooltip could remain visible after opening the rating information modal and then closing it. Root cause The percentile tooltip was uncontrolled and stayed mounted while the rating modal was open, so its hover state could survive the modal overlay lifecycle. What was changed Disable the percentile tooltip whenever the rating information modal is open, which unmounts the tooltip until the modal closes. Any added/updated tests Updated MemberRatingCard tests to assert the percentile tooltip is disabled while the rating modal is open and restored after closing. Validation: - yarn test:no-watch src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx - yarn lint - yarn run build - yarn test:no-watch (attempted; existing unrelated failures in work, engagements, and wallet-admin specs) --- .../MemberRatingCard.spec.tsx | 64 ++++++++++++++++++- .../MemberRatingCard/MemberRatingCard.tsx | 1 + 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx index 67033a0f6..5e5e20745 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx @@ -1,13 +1,15 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ import '@testing-library/jest-dom' import type { PropsWithChildren } from 'react' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import type { UserProfile, UserStats, UserStatsDistributionResponse } from '~/libs/core' import { useMemberStats, useStatsDistribution } from '~/libs/core' import MemberRatingCard from './MemberRatingCard' +const mockTooltip = jest.fn((props: PropsWithChildren<{ disableTooltip?: boolean }>) => <>{props.children}) + jest.mock('~/libs/core', () => ({ getRatingColor: jest.fn(() => '#616BD5'), useMemberStats: jest.fn(), @@ -17,7 +19,7 @@ jest.mock('~/libs/core', () => ({ }) jest.mock('~/libs/ui', () => ({ - Tooltip: (props: PropsWithChildren) => <>{props.children}, + Tooltip: (props: PropsWithChildren<{ disableTooltip?: boolean }>) => mockTooltip(props), }), { virtual: true, }) @@ -31,7 +33,11 @@ jest.mock('../../../lib', () => ({ })) jest.mock('./MemberRatingInfoModal', () => ({ - MemberRatingInfoModal: () =>
    , + MemberRatingInfoModal: (props: { onClose: () => void }) => ( +
    + +
    + ), })) jest.mock('./ModifyPreferredRolesModal', () => ({ @@ -61,9 +67,24 @@ const defaultProps = { profile, } +/** + * Returns the props from the latest mocked Tooltip render. + * Used to verify the rating card disables its percentile tooltip while the rating modal is open. + * + * @returns The most recent Tooltip props captured by the mock. + */ +function getLastTooltipProps(): PropsWithChildren<{ disableTooltip?: boolean }> { + const lastTooltipCall = mockTooltip.mock.calls[mockTooltip.mock.calls.length - 1] + + return lastTooltipCall[0] +} + describe('MemberRatingCard', () => { beforeEach(() => { jest.clearAllMocks() + mockTooltip.mockImplementation((props: PropsWithChildren<{ disableTooltip?: boolean }>) => ( + <>{props.children} + )) mockedUseStatsDistribution.mockReturnValue(ratingDistribution) }) @@ -125,4 +146,41 @@ describe('MemberRatingCard', () => { expect(screen.getByText('Data Scientists')) .toBeInTheDocument() }) + + it('disables the percentile tooltip while the rating modal is open', () => { + mockedUseMemberStats.mockReturnValue({ + DATA_SCIENCE: { + MARATHON_MATCH: { + mostRecentEventDate: 1000, + rank: { + percentile: 42, + rating: 1200, + }, + }, + }, + maxRating: { + rating: 1200, + }, + } as unknown as UserStats) + + render() + + expect(getLastTooltipProps().disableTooltip) + .toBe(false) + + fireEvent.click(screen.getByRole('button', { name: /Top 70% Data Scientists/i })) + + expect(screen.getByRole('dialog')) + .toBeInTheDocument() + expect(getLastTooltipProps().disableTooltip) + .toBe(true) + + fireEvent.click(screen.getByRole('button', { name: 'Close rating modal' })) + + expect(screen.queryByRole('dialog')) + .not + .toBeInTheDocument() + expect(getLastTooltipProps().disableTooltip) + .toBe(false) + }) }) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx index d443105b4..497f12892 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx @@ -166,6 +166,7 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp {audienceLabel.toLowerCase()} )} + disableTooltip={isInfoModalOpen} place='top' > - { - percentileLabel ? ( - - {percentileLabel} - {' '} - of -
    - 2M - {' '} - {audienceLabel.toLowerCase()} - - )} - disableTooltip={isInfoModalOpen} - place='top' - > - + { + percentileLabel ? ( + + {percentileLabel} + {' '} + of +
    + 2M + {' '} + {audienceLabel.toLowerCase()} + + )} + disableTooltip={isInfoModalOpen} + place='top' > -

    - {percentileLabel} -

    -

    {audienceLabel}

    - -
    - ) : undefined - } - -
    +

    + {percentileLabel} +

    +

    {audienceLabel}

    + + + ) : undefined + } + +
    + )} { - isInfoModalOpen && ( + isInfoModalOpen && hasRating && ( Date: Tue, 23 Jun 2026 09:53:26 +1000 Subject: [PATCH 73/73] Stats fixes --- .../StatsDetailsLayout/StatsDetailsLayout.tsx | 4 +-- .../src/hooks/useFetchActiveTracks.spec.tsx | 11 ++++++- .../src/hooks/useFetchActiveTracks.tsx | 30 +++++++++++++++++-- .../tc-achievements/track-view/TrackView.tsx | 4 +-- 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/apps/profiles/src/components/tc-achievements/StatsDetailsLayout/StatsDetailsLayout.tsx b/src/apps/profiles/src/components/tc-achievements/StatsDetailsLayout/StatsDetailsLayout.tsx index 1d89f258c..fd87b6773 100644 --- a/src/apps/profiles/src/components/tc-achievements/StatsDetailsLayout/StatsDetailsLayout.tsx +++ b/src/apps/profiles/src/components/tc-achievements/StatsDetailsLayout/StatsDetailsLayout.tsx @@ -5,7 +5,7 @@ import { MemberStats } from '~/libs/core' import { StatsNavHeader } from '../StatsNavHeader' import { StatsSummaryBlock } from '../StatsSummaryBlock' -import { getSubTrackSubmissionCount, MemberStatsTrack } from '../../../hooks/useFetchActiveTracks' +import { getSubTrackDisplaySubmissionCount, MemberStatsTrack } from '../../../hooks/useFetchActiveTracks' import styles from './StatsDetailsLayout.module.scss' @@ -35,7 +35,7 @@ const StatsDetailsLayout: FC = props => ( challenges={props.trackData.challenges} wins={props.trackData.wins} submissions={ - getSubTrackSubmissionCount(props.trackData as MemberStats) + getSubTrackDisplaySubmissionCount(props.trackData as MemberStats) ?? (typeof props.trackData.submissions === 'number' ? props.trackData.submissions : undefined) } ranking={(props.trackData as MemberStats).rank?.rank} diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx index bdf7fd0ad..45848473f 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx @@ -1,6 +1,11 @@ import type { UserStats } from '~/libs/core' -import { getActiveTracks, getMemberChallengePoints, MemberStatsTrack } from './useFetchActiveTracks' +import { + getActiveTracks, + getMemberChallengePoints, + getSubTrackDisplaySubmissionCount, + MemberStatsTrack, +} from './useFetchActiveTracks' jest.mock('~/libs/core', () => ({ useMemberStats: jest.fn(), @@ -291,6 +296,7 @@ describe('getActiveTracks', () => { .toEqual(expect.objectContaining({ challenges: 3, isActive: true, + submissions: 3, wins: 1, })) expect(aiSubTrack) @@ -305,6 +311,8 @@ describe('getActiveTracks', () => { }), wins: 1, })) + expect(getSubTrackDisplaySubmissionCount(aiSubTrack)) + .toEqual(3) expect(dataScienceTrack) .toEqual(expect.objectContaining({ challenges: 3, @@ -355,6 +363,7 @@ describe('getActiveTracks', () => { isDSTrack: true, percentile: 12, rating: 1422, + submissions: 3, wins: 1, })) expect(javaMySQLTrack?.subTracks) diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx index 5839a2e18..c7ffb8db9 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx @@ -71,6 +71,29 @@ export const getSubTrackSubmissionCount = (subTrack?: MemberStats): number | und return typeof submissionCount === 'number' ? submissionCount : undefined } +/** + * Return the submission count to display in the profile stats UI. + * + * Unified Data Science and configured rating-path rows can omit explicit + * submission counters even though their challenge count is sourced from valid + * submissions. In that case, use challenge participation count so the profile + * does not display impossible zero-submission wins. + * + * @param {MemberStats | undefined} subTrack - The subtrack to inspect. + * @returns {number | undefined} Explicit submissions, or challenge count as a fallback. + */ +export const getSubTrackDisplaySubmissionCount = (subTrack?: MemberStats): number | undefined => { + const submissionCount = getSubTrackSubmissionCount(subTrack) + + if (submissionCount !== undefined) { + return submissionCount + } + + return typeof subTrack?.challenges === 'number' && subTrack.challenges > 0 + ? subTrack.challenges + : undefined +} + /** * Determine whether the subtrack should be considered active. * @@ -81,7 +104,7 @@ export const getSubTrackSubmissionCount = (subTrack?: MemberStats): number | und * @returns {boolean} Whether the subtrack has activity worth rendering. */ const isActiveSubTrack = (subTrack?: MemberStats): boolean => { - const submissionCount = getSubTrackSubmissionCount(subTrack) + const submissionCount = getSubTrackDisplaySubmissionCount(subTrack) return (submissionCount ?? 0) > 0 || (subTrack?.challenges ?? 0) > 0 } @@ -229,9 +252,9 @@ const buildTrackData = (trackName: string, allSubTracks: MemberStats[]): MemberS 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 + (getSubTrackSubmissionCount(subTrack) ?? 0) + sum + (getSubTrackDisplaySubmissionCount(subTrack) ?? 0) ), 0) - const hasSubmissionCounts = subTracks.some(subTrack => getSubTrackSubmissionCount(subTrack) !== undefined) + const hasSubmissionCounts = subTracks.some(subTrack => getSubTrackDisplaySubmissionCount(subTrack) !== undefined) // Return aggregated track data return { @@ -475,6 +498,7 @@ const buildDataScienceRatingPathTrackData = ( percentile: getFiniteNumber(ratingPathStats.rank?.overallPercentile) ?? getFiniteNumber(ratingPathStats.rank?.percentile), rating: getFiniteNumber(ratingPathStats.rank?.rating), + submissions: getSubTrackDisplaySubmissionCount(subTrack), subTracks: [subTrack], wins: getFiniteNumber(ratingPathStats.wins) ?? 0, } diff --git a/src/apps/profiles/src/member-profile/tc-achievements/track-view/TrackView.tsx b/src/apps/profiles/src/member-profile/tc-achievements/track-view/TrackView.tsx index 69ba5b061..61e905eb3 100644 --- a/src/apps/profiles/src/member-profile/tc-achievements/track-view/TrackView.tsx +++ b/src/apps/profiles/src/member-profile/tc-achievements/track-view/TrackView.tsx @@ -4,7 +4,7 @@ import { orderBy } from 'lodash' import { MemberStats, UserProfile } from '~/libs/core' -import { getSubTrackSubmissionCount, useFetchTrackData } from '../../../hooks' +import { getSubTrackDisplaySubmissionCount, useFetchTrackData } from '../../../hooks' import { StatsDetailsLayout } from '../../../components/tc-achievements/StatsDetailsLayout' import { SubTrackSummaryCard } from '../../../components/tc-achievements/SubTrackSummaryCard' import { MemberProfileContextValue, useMemberProfileContext } from '../../MemberProfile.context' @@ -47,7 +47,7 @@ const TrackView: FC = props => { key={subTrack.name} title={subTrack.name} wins={subTrack.wins} - submissions={getSubTrackSubmissionCount(subTrack) ?? 0} + submissions={getSubTrackDisplaySubmissionCount(subTrack) ?? 0} /> ))}