From 670fdf00b0e4a2d5871317dd0bb3ae08a85c24e3 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 4 Jun 2026 10:25:13 +1000 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 +}