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-.*/ 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/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..d6c705d2f --- /dev/null +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.module.scss @@ -0,0 +1,111 @@ +@import "@libs/ui/styles/includes"; + +.modal { + width: 640px !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-4; + min-width: 0; +} + +.divider { + border: 0; + border-top: 1px solid $black-10; + margin: 0; +} + +.table { + overflow: hidden; +} + +.tableHeader, +.tableRow { + display: grid; + gap: $sp-3; + grid-template-columns: 56px minmax(0, 1fr) 72px; + + @include ltesm { + gap: $sp-2; + grid-template-columns: 44px minmax(0, 1fr) 52px; + } +} + +.tableHeader { + border-bottom: 1px solid $black-20; + color: $black-100; + font-family: $font-roboto; + font-size: 14px; + font-weight: $font-weight-bold; + line-height: 20px; + padding: $sp-3 0; +} + +.tableRow { + align-items: center; + color: $black-100; + font-family: $font-roboto; + font-size: 14px; + line-height: 20px; + min-height: 52px; + padding: $sp-3 0; + + & + & { + border-top: 1px solid $black-10; + } +} + +.challengeLink { + color: $turq-160; + min-width: 0; + overflow-wrap: anywhere; + + &: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..08aad3ed9 --- /dev/null +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.tsx @@ -0,0 +1,87 @@ +import { FC } from 'react' + +import { EnvironmentConfig } from '~/config' +import { UserChallengePointsDetail, UserChallengePointsSummary } from '~/libs/core' +import { BaseModal } from '~/libs/ui' + +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: UserChallengePointsDetail[] = props.challengePoints.details ?? [] + + return ( + + POINTS BREAKDOWN + + )} + size='md' + > +
+
+ +
+
+ Place + Challenge + Points +
+ + {details.map((detail: UserChallengePointsDetail) => ( +
+ + {detail.placement} + + + {detail.challengeName || `Challenge ${detail.challengeId}`} + + + {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 a3b4e9233..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,31 +6,106 @@ container-type: inline-size; } +.challengePointsStandalone { + container-type: inline-size; + margin: $sp-4 0 0; + width: 100%; +} + +.challengePointsBar { + display: flex; + align-items: center; + 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: 18px; + line-height: 24px; + + @include ltesm { + flex-wrap: wrap; + min-height: 86px; + padding: $sp-4; + } +} + +.challengePointsLabel { + font-weight: $font-weight-bold; +} + +.challengePointsValue { + font-family: $font-barlow-condensed; + font-size: 32px; + font-weight: $font-weight-medium; + line-height: 34px; +} + +.challengePointsMeta { + color: rgba($tc-white, 0.78); +} + +.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; + } +} + .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-8 $sp-9 $sp-7; min-height: 100%; @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-3; + margin-bottom: $sp-5; } .footerNote { - margin-top: $sp-4; + margin-top: $sp-5; + color: rgba($tc-white, 0.88); + + :global(.body-main) { + font-size: 18px; + line-height: 28px; + } } .innerWrapper { @@ -41,68 +116,77 @@ } .statsList { - display: flex; - flex-wrap: wrap; - gap: $sp-1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: $sp-2 $sp-4; + + margin: 0; - margin: auto 0; + @container (max-width: 520px) { + grid-template-columns: 1fr; + } + + > li { + min-width: 0; + } } .trackListItem { display: flex; align-items: center; justify-content: space-between; - min-height: 62px; - padding: $sp-2 $sp-3; - background: rgba($tc-white, 0.05); - border: 1px solid rgba($tc-white, 0.25); - border-radius: $sp-2; + 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; - - - @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: 136px; + > svg { flex: 0 0 auto; } } .rightArrowIcon { - margin-left: $sp-3; + margin-left: $sp-4; + color: rgba($tc-white, 0.9); +} + +.winnerIcon { + color: #F2C900; + margin-right: $sp-3; } .trackStats { display: flex; flex-direction: column; text-align: right; - margin-left: $sp-1; + min-width: 58px; + .count { font-family: $font-barlow-condensed; font-weight: $font-weight-medium; - font-size: 26px; - line-height: 34px; + font-size: 34px; + line-height: 36px; } + .label { font-family: $font-roboto; font-weight: $font-weight-medium; - font-size: 11px; - line-height: 12px; + font-size: 14px; + line-height: 18px; + color: rgba($tc-white, 0.82); } } @@ -112,4 +196,81 @@ background: currentColor; border-radius: 50%; + margin-right: $sp-3; +} + +.trackName { + min-width: 0; + padding-right: $sp-2; + overflow: hidden; + font-family: $font-roboto; + 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 cb8a35767..be35f4231 100644 --- a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.tsx @@ -1,31 +1,261 @@ -import { FC, useCallback } from 'react' +import { Dispatch, FC, SetStateAction, useCallback, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import classNames from 'classnames' -import { getRatingColor, MemberStats, UserProfile } from '~/libs/core' +import { + getRatingColor, + MemberStats, + useMemberStats, + UserChallengePointsSummary, + 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' +import MemberChallengePointsModal from './MemberChallengePointsModal' import styles from './MemberStatsBlock.module.scss' interface MemberStatsBlockProps { profile: UserProfile } +interface MemberChallengePointsBarProps { + memberStats?: UserStats + profile: UserProfile +} + +interface TrackDisplayStats { + indicator?: 'rating' | 'winner' + label: string + 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', + '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) + }) +) + +/** + * 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 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. + * + * @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 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 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 ? <> : (
    @@ -35,66 +265,12 @@ 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', - )} - - - - )} - {/* competitive programming only */} - {track.isDSTrack && ( - (track.isCPTrack || (track.percentile as number) < 50) ? ( - <> - - - - {track.rating} - - - Rating - - - - ) : ( - - - {track.percentile} - % - - - Percentile - - - ) - )} - -
      - + track={track} + /> ))}

    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/hooks/useFetchActiveTracks.spec.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx index 1023542d2..9fcd6d085 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,80 @@ 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) + }) + + 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 34628707b..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, 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. */ @@ -24,6 +41,7 @@ export interface MemberStatsTrack { percentile?: number, submissionRate?: number screeningSuccessRate?: number + challengePoints?: number wins: number, order?: number isDSTrack?: boolean @@ -82,7 +100,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 +114,58 @@ const mapSubTracksByName = ( }, {} as {[key: string]: MemberStats}) ?? {} ) +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. + * + * @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 +219,112 @@ 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, + } +} + +/** + * 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. * @@ -188,6 +364,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( @@ -217,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, @@ -250,11 +430,13 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => // Order and filter active tracks based on wins and submissions return orderBy(filter([ + aiEngineeringTrackStats, dsTrackStats, cpTrackStats, designTrackStats, developTrackStats, testingTrackStats, + ...dataScienceRatingPathTrackStats, ], { isActive: true }), ['order', 'wins', 'submissions'], ['desc', 'desc', 'desc']) } 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.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..10196bf77 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) } @@ -81,7 +99,12 @@ const AboutMe: FC = (props: AboutMeProps) => { }

    - +

    {memberTitle}

    @@ -109,7 +132,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/about-me/MemberRatingCard/MemberRatingCard.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss index 4e5d511d4..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 @@ -5,24 +5,36 @@ width: 100%; .innerWrap { - background-image: linear-gradient(90deg, #05456D, #0A7AC0); + background: #07142F; color: $tc-white; - display: flex; - justify-content: space-between; - padding: $sp-4; - border-radius: 16px; + display: grid; + 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%; + margin: 0 auto; .valueWrap { + appearance: none; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; display: flex; flex-direction: column; - align-items: center; - - &.noPercentile { - flex-direction: row; - align-items: flex-end; + align-items: flex-start; + min-width: 0; + padding: 0; + text-align: left; + &:hover { .name { - margin-left: $sp-2; + text-decoration: underline; } } @@ -31,19 +43,145 @@ font-weight: 500; font-family: $font-barlow-condensed; line-height: 28px; + white-space: nowrap; } .name { + color: rgba($tc-white, 0.84); + font-family: $font-roboto; font-size: 12px; - line-height: 18px; + line-height: 14px; + } + + .percentileValue { + background: rgba($tc-white, 0.06); + border-radius: 2px; + font-family: $font-roboto; + font-size: 14px; + 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 { - font-size: 14px; + grid-column: -2 / -1; + align-self: start; + justify-self: end; + margin-top: $sp-1; + color: $tc-white; + font-size: 12px; line-height: 14px; font-weight: $font-weight-medium; font-family: $font-roboto; + white-space: nowrap; + + &:hover { + text-decoration: underline; + } + } + + @include ltelg { + grid-template-columns: 52px 104px auto; + } + + @include ltesm { + 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: 1; + justify-self: start; + margin-top: 0; + } } } -} \ No newline at end of file +} + +.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; + + 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 3ca7e39d0..6607d3c08 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,41 +1,89 @@ -/* 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, + UserStatsDistributionResponse, + UserTrait, + useStatsDistribution, +} from '~/libs/core' +import { Tooltip } from '~/libs/ui' +import { EditMemberPropertyBtn } from '../../../components' +import { getPreferredRolesText, numberToFixed } from '../../../lib' + +import { + calculateTopPercentileFromDistribution, + getLatestProfileRating, + getPreferredRolesDisplay, + 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 } +/** + * Formats percentile values for the compact rating card. + * + * @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 => ( + numberToFixed(percentile, 0) +) + 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) - 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 [isPreferredRolesModalOpen, setIsPreferredRolesModalOpen]: [ + boolean, + Dispatch> + ] = useState(false) - if (memberStats?.DEVELOP) { - memberStats.DEVELOP.subTracks.forEach((subTrack: any) => { - const subPercentile = subTrack.rank.percentile || subTrack.rank.overallPercentile || 0 - if (subPercentile > memberPercentile) { - memberPercentile = subPercentile - } - }) - } + const [arePreferredRolesExpanded, setArePreferredRolesExpanded]: [ + boolean, + Dispatch> + ] = useState(false) + + const ratingDistributionQuery = useMemo(() => getRatingDistributionQuery(memberStats), [memberStats]) + + const ratingDistribution: UserStatsDistributionResponse | undefined = useStatsDistribution(ratingDistributionQuery) - return memberPercentile - }, [memberStats]) + 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 + 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], + ) + const preferredRolesDisplay = useMemo( + () => getPreferredRolesDisplay(preferredRoles, arePreferredRolesExpanded), + [arePreferredRolesExpanded, preferredRoles], + ) function handleInfoModalClose(): void { setIsInfoModalOpen(false) @@ -45,33 +93,127 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp setIsInfoModalOpen(true) } - return memberStats?.maxRating?.rating ? ( + 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 { + if (preferredRoles.length === 0 && !canEditPreferredRoles) { + return <> + } + + return ( +
    + {preferredRoles.length > 0 && ( +
    + {preferredRolesDisplay.visibleRoles.map((role: string) => ( + + {role} + + ))} + + {preferredRolesDisplay.toggleLabel && ( + + )} +
    + )} + + {canEditPreferredRoles && ( + + )} +
    + ) + } + + return rating !== undefined ? (
    -
    -

    {memberStats?.maxRating?.rating}

    +
    + { - maxPercentile ? ( -
    -

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

    -

    Percentile

    -
    + percentileLabel ? ( + + {percentileLabel} + {' '} + of +
    + 2M + {' '} + {audienceLabel.toLowerCase()} + + )} + place='top' + > + +
    ) : undefined } -
    - -
    +
    { isInfoModalOpen && ( + ) + } + + {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 new file mode 100644 index 000000000..590c0bc8e --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts @@ -0,0 +1,317 @@ +import type { UserStats, UserStatsDistributionResponse } from '~/libs/core' + +import { + calculateTopPercentileFromDistribution, + getLatestProfileRating, + getPreferredRolesDisplay, + getRatingAudienceLabel, + getRatingDistributionQuery, + parsePreferredRolesText, +} 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) + }) +}) + +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') + }) + + it('uses the latest rating track for the audience label', () => { + expect(getRatingAudienceLabel({ + DATA_SCIENCE: { + SRM: { + mostRecentEventDate: 1000, + rank: { + rating: 1400, + }, + }, + }, + DEVELOP: { + subTracks: [{ + mostRecentEventDate: 2000, + name: 'Challenge', + rank: { + rating: 1200, + }, + }], + }, + maxRating: { + rating: 1400, + ratingColor: 'blue', + subTrack: 'SRM', + track: 'DATA_SCIENCE', + }, + } as unknown as UserStats)) + .toBe('Developers') + }) +}) + +describe('getRatingDistributionQuery', () => { + it('uses the fallback max 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('uses the latest rating track and subtrack for distribution lookup', () => { + expect(getRatingDistributionQuery({ + DATA_SCIENCE: { + SRM: { + mostRecentEventDate: 1000, + rank: { + rating: 1400, + }, + }, + }, + DEVELOP: { + subTracks: [{ + mostRecentEventDate: 2000, + name: 'Challenge', + rank: { + rating: 1200, + }, + }], + }, + maxRating: { + rating: 1400, + ratingColor: 'blue', + subTrack: 'SRM', + track: 'DATA_SCIENCE', + }, + } as unknown 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', + }) + }) +}) + +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']) + }) +}) + +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 new file mode 100644 index 000000000..adeab2968 --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts @@ -0,0 +1,422 @@ +import { UserStats, UserStatsDistributionResponse } from '~/libs/core' + +interface RatingCandidate { + rating: number + ratingDate: number + subTrack: string + track: string +} + +interface RatingDistributionQuery { + subTrack: string + track: string +} + +interface RatingDistributionRange { + end: number + start: number + value: number +} + +export interface PreferredRolesDisplay { + hiddenCount: number + toggleLabel: string | undefined + visibleRoles: string[] +} + +type StatsRecord = Record + +const maxCollapsedPreferredRoles = 2 + +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} = { + AI: 'Data Scientists', + AI_ENGINEER: 'Data Scientists', + AI_ENGINEERING: 'Data Scientists', + 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 +) + +/** + * 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) +) + +/** + * Returns a stat entry's display name when it is present. + * + * @param {unknown} stats - Raw subtrack or rating-path stats from the member stats API. + * @param {string} fallbackName - Name to use when the stats object does not include one. + * @returns {string} A usable subtrack name for rating metadata. + */ +const getStatsName = (stats: unknown, fallbackName: string): string => { + if (!isStatsRecord(stats) || typeof stats.name !== 'string') { + return fallbackName + } + + const statsName = stats.name.trim() + + return statsName || fallbackName +} + +/** + * 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. + * @param {string} track - API track that owns the stats entry. + * @param {string} subTrack - API subtrack or rating path for the stats entry. + * @returns {RatingCandidate | undefined} Rating, event date, and track metadata when the stats are rated. + */ +const getRatingCandidate = ( + stats: unknown, + track: string, + subTrack: string, +): 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, + subTrack, + track, + } +} + +/** + * 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) +) + +/** + * 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. + * + * @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 visible member 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 +} + +/** + * Extracts rated candidates from a design or development subtrack list. + * + * @param {string} track - API track that owns the subtracks. + * @param {unknown} subTracks - Raw `subTracks` array from member stats. + * @returns {RatingCandidate[]} Rated subtracks available for profile rating selection. + */ +const getSubTrackRatingCandidates = (track: string, subTracks: unknown): RatingCandidate[] => ( + Array.isArray(subTracks) + ? subTracks + .map((subTrack: unknown) => getRatingCandidate( + subTrack, + track, + getStatsName(subTrack, track), + )) + .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.entries(dataScienceStats) + .map(([subTrack, stats]: [string, unknown]) => getRatingCandidate(stats, 'DATA_SCIENCE', subTrack)) + .filter((candidate: RatingCandidate | undefined): candidate is RatingCandidate => candidate !== undefined) + : [] +) + +/** + * Extracts rated candidates from the known AI Engineering top-level stat aliases. + * + * @param {UserStats | undefined} memberStats - Raw member stats for the profile user. + * @returns {RatingCandidate[]} Rated AI Engineering entries available for profile rating selection. + */ +const getAIEngineeringRatingCandidates = (memberStats?: UserStats): RatingCandidate[] => { + const aiEngineeringStats = memberStats?.AI_ENGINEERING ?? memberStats?.AI ?? memberStats?.AI_ENGINEER + + return [ + getRatingCandidate(aiEngineeringStats, 'AI_ENGINEERING', getStatsName(aiEngineeringStats, 'AI')), + ...getSubTrackRatingCandidates('AI_ENGINEERING', aiEngineeringStats?.subTracks), + ].filter((candidate: RatingCandidate | undefined): candidate is RatingCandidate => candidate !== undefined) +} + +/** + * Builds the fallback rating candidate from the historical maximum rating payload. + * + * @param {UserStats | undefined} memberStats - Raw member stats for the profile user. + * @returns {RatingCandidate | undefined} Max rating candidate when enough metadata is available. + */ +const getMaxRatingCandidate = (memberStats?: UserStats): RatingCandidate | undefined => { + const maxRating = memberStats?.maxRating + const rating = getFiniteNumber(maxRating?.rating) + + if (rating === undefined || !maxRating?.track || !maxRating?.subTrack) { + return undefined + } + + return { + rating, + ratingDate: 0, + subTrack: maxRating.subTrack, + track: maxRating.track, + } +} + +/** + * Returns the latest rated track candidate used by the compact rating card. + * + * @param {UserStats | undefined} memberStats - Raw member stats for the profile user. + * @returns {RatingCandidate | undefined} Latest current rating candidate, or max rating fallback. + */ +const getLatestProfileRatingCandidate = (memberStats?: UserStats): RatingCandidate | undefined => { + const candidates: RatingCandidate[] = [ + ...getSubTrackRatingCandidates('DEVELOP', memberStats?.DEVELOP?.subTracks), + ...getSubTrackRatingCandidates('DESIGN', memberStats?.DESIGN?.subTracks), + ...getDataScienceRatingCandidates(memberStats?.DATA_SCIENCE), + ...getAIEngineeringRatingCandidates(memberStats), + ] + + const latestCandidate: RatingCandidate | undefined = candidates.reduce(( + latest: RatingCandidate | undefined, + candidate: RatingCandidate, + ) => ( + latest === undefined || candidate.ratingDate > latest.ratingDate ? candidate : latest + ), undefined) + + return latestCandidate ?? getMaxRatingCandidate(memberStats) +} + +/** + * 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 => ( + getLatestProfileRatingCandidate(memberStats)?.rating +) + +/** + * Checks whether a rating candidate should use the configured AI Engineering distribution. + * + * @param {RatingCandidate} ratingCandidate - The selected profile rating candidate. + * @returns {boolean} True when the candidate maps to the configured AI distribution. + */ +const isAIEngineeringRatingCandidate = (ratingCandidate: RatingCandidate): boolean => ( + aiEngineeringTrackNames.has(normalizeTrackToken(ratingCandidate.track)) + || aiEngineeringTrackNames.has(normalizeTrackToken(ratingCandidate.subTrack)) +) + +/** + * Returns the distribution query that corresponds to the visible profile rating. + * + * @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 ratingCandidate = getLatestProfileRatingCandidate(memberStats) + + if (!ratingCandidate) { + return undefined + } + + if (isAIEngineeringRatingCandidate(ratingCandidate)) { + return { + subTrack: 'AI', + track: 'DATA_SCIENCE', + } + } + + return { + subTrack: ratingCandidate.subTrack, + track: ratingCandidate.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 ratingCandidate = getLatestProfileRatingCandidate(memberStats) + const normalizedTrack = normalizeTrackToken(ratingCandidate?.track) + const normalizedSubTrack = normalizeTrackToken(ratingCandidate?.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 new file mode 100644 index 000000000..ef6da8731 --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss @@ -0,0 +1,353 @@ +@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-barlow; + font-size: 22px; + font-weight: $font-weight-bold; + line-height: 28px; + 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: 16px; + line-height: 24px; + margin: 0; +} + +.summaryPanel { + align-items: stretch; + border: 1px solid $black-10; + border-radius: 6px; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr); + min-height: 96px; + overflow: hidden; + + @include ltesm { + grid-template-columns: minmax(0, 1fr); + } +} + +.summaryMetric { + display: flex; + flex-direction: column; + gap: 2px; + justify-content: center; + min-width: 0; + padding: $sp-3 $sp-6; + + @include ltesm { + padding: $sp-4; + } + + &:first-child { + @include ltesm { + grid-column: 1 / -1; + } + } +} + +.positionMetric { + align-items: center; + border-left: 1px solid $black-10; + flex-direction: row; + gap: $sp-6; + justify-content: flex-start; + + @include ltesm { + 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; + font-size: 16px; + font-weight: $font-weight-bold; + line-height: 22px; +} + +.ratingValue { + font-family: $font-barlow-condensed; + font-size: 32px; + font-weight: $font-weight-medium; + line-height: 34px; + white-space: nowrap; +} + +.positionValue { + font-family: $font-barlow-condensed; + font-size: 32px; + font-weight: $font-weight-medium; + line-height: 34px; + text-transform: uppercase; + white-space: nowrap; +} + +.summaryMeta { + color: $black-60; + font-family: $font-roboto; + font-size: 12px; + line-height: 16px; +} + +.tierPyramid { + align-items: center; + display: flex; + flex: 0 0 76px; + justify-content: center; + min-width: 0; +} + +.tierPyramidSvg { + display: block; + height: 69px; + width: 76px; +} + +.sectionTitle { + color: $black-100; + font-family: $font-roboto; + font-size: 16px; + font-weight: $font-weight-bold; + line-height: 22px; + margin: $sp-1 0 0; + text-transform: none; +} + +.chart { + background: $black-5; + border-radius: 6px; + height: 228px; + min-width: 0; + overflow: hidden; + 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: 0; + position: absolute; + right: 0; +} + +.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: 0; + line-height: 12px; + position: absolute; + right: 0; + + 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: 12px; + font-weight: $font-weight-bold; + line-height: 16px; +} + +.legendLabel { + color: $black-80; + font-family: $font-roboto; + 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 new file mode 100644 index 000000000..eb109e2a2 --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx @@ -0,0 +1,80 @@ +/* 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() + expect(screen.getByText('Where Emily ranks in the distribution')) + .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 10fa2896c..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 @@ -1,23 +1,464 @@ -import { FC } from 'react' +import { CSSProperties, FC, useMemo } from 'react' +import classNames from 'classnames' +import { getRatingColor, UserProfile, UserStatsDistributionResponse } 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 + 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 } -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. -

    -
    +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 => ( + rating === undefined ? 'Unrated' : getRatingTier(rating).tierLabel +) + +/** + * 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+)/) + + 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 + } + + 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 + } + + 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 Math.max(4, Math.round((value / maxValue) * 100)) +} + +/** + * 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' + > +
    +
    + +

    + 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 === '--' ? '' : '%'} + + + of + {' '} + {props.audienceLabel.toLowerCase()} + +
    +
    +
    + +

    + 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/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/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.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 b5e8d0af5..c67f08155 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,30 @@ -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 { Tooltip } 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 +33,70 @@ 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 => ( -
    ( + -
    +
    - {badge.org_badge.badge_name} -
    + + )) }
    + {!isAwardsExpanded && additionalBadgeCount > 0 && ( + + )} + { selectedBadge && ( ) 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/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..1523bbf53 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 + && !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..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,11 +1,11 @@ -import { FC } from 'react' +import { FC, useMemo } from 'react' import { UserProfile, UserStats } from '~/libs/core' -import { CommunityAwards } from '../../community-awards' -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' @@ -17,25 +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 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 diff --git a/src/libs/core/lib/profile/user-stats.model.ts b/src/libs/core/lib/profile/user-stats.model.ts index 93cba462c..48b838715 100644 --- a/src/libs/core/lib/profile/user-stats.model.ts +++ b/src/libs/core/lib/profile/user-stats.model.ts @@ -57,10 +57,47 @@ 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 +} + +/** + * 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 handleLower: string + challengePoints?: number + CHALLENGE_POINTS?: number challenges: number userId: number wins: number @@ -79,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 @@ -102,6 +131,9 @@ export type UserStats = { subTracks: Array wins: number } + AI?: MemberStatsGroup + AI_ENGINEER?: MemberStatsGroup + AI_ENGINEERING?: MemberStatsGroup } export type StatsHistory = { 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} - /> )}