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/.environments/.env.prod b/.environments/.env.prod index b4c21353f..1a460a189 100644 --- a/.environments/.env.prod +++ b/.environments/.env.prod @@ -14,6 +14,9 @@ REACT_APP_MEMBER_VERIFY_LOOKER=3322 REACT_APP_SPRIG_ENV_ID=a-IZBZ6-r7bU # Filestack configuration for uploading Submissions -REACT_APP_FILESTACK_API_KEY= -REACT_APP_FILESTACK_REGION= -REACT_APP_FILESTACK_SUBMISSION_CONTAINER= +REACT_APP_FILESTACK_API_KEY='AzFINuQoqTmqw0QEoaw9az' +REACT_APP_FILESTACK_REGION=us-east-1 +REACT_APP_FILESTACK_SUBMISSION_CONTAINER='topcoder-submissions-dmz' +REACT_APP_FILESTACK_UPLOAD_PROGRESS_INTERVAL=100 +REACT_APP_FILESTACK_UPLOAD_RETRY=2 +REACT_APP_FILESTACK_UPLOAD_TIMEOUT=1800000 diff --git a/src/apps/engagements/src/lib/hooks/useTermsAgreementGate.ts b/src/apps/engagements/src/lib/hooks/useTermsAgreementGate.ts index c1cadd183..30c4551f6 100644 --- a/src/apps/engagements/src/lib/hooks/useTermsAgreementGate.ts +++ b/src/apps/engagements/src/lib/hooks/useTermsAgreementGate.ts @@ -8,7 +8,11 @@ import { getDocuSignUrl, getTermDetails, } from '../services' -import { extractTermId } from '../utils' +import { + extractTermId, + resolveDocuSignTemplateId, + resolveStandardTermsConfig, +} from '../utils' type TermsConfig = { id: string @@ -56,11 +60,15 @@ type TermsViewData = { isElectronicallyAgreeable: boolean } -const TERMS_ID = extractTermId(EnvironmentConfig.TERMS_URL) +const STANDARD_TERMS = resolveStandardTermsConfig( + EnvironmentConfig.DEFAULT_STANDARD_TERMS_UUID, + EnvironmentConfig.TERMS_URL, +) +const TERMS_ID = STANDARD_TERMS.id const NDA_TERMS_ID = extractTermId(EnvironmentConfig.NDA_TERMS_URL) const TERMS_CONFIG: TermsConfig[] = [ - { id: TERMS_ID ?? '', label: 'Standard Topcoder Terms', url: EnvironmentConfig.TERMS_URL }, + { id: TERMS_ID ?? '', label: 'Standard Topcoder Terms', url: STANDARD_TERMS.url }, { id: NDA_TERMS_ID ?? '', label: 'Topcoder NDA', url: EnvironmentConfig.NDA_TERMS_URL }, ].filter(term => term.id) @@ -281,11 +289,12 @@ export const useTermsAgreementGate = ( () => getTermsViewData(termsDetails), [termsDetails], ) - const docuSignTemplateId = termsDetails?.docusignTemplateId + const docuSignTemplateId = resolveDocuSignTemplateId( + termsDetails, + EnvironmentConfig.NDA_DOCUSIGN_TEMPLATE_ID, + ) const isDocuSignTerm = Boolean( - termsDetails?.agreeabilityType - && termsDetails.agreeabilityType !== 'Electronically-agreeable' - && docuSignTemplateId, + termsDetails && docuSignTemplateId, ) const termsUrl = activeTerm?.url || termsDetails?.url diff --git a/src/apps/engagements/src/lib/utils/terms.utils.spec.ts b/src/apps/engagements/src/lib/utils/terms.utils.spec.ts new file mode 100644 index 000000000..a8c36079b --- /dev/null +++ b/src/apps/engagements/src/lib/utils/terms.utils.spec.ts @@ -0,0 +1,55 @@ +import { + extractTermId, + replaceTermIdInUrl, + resolveDocuSignTemplateId, + resolveStandardTermsConfig, +} from './terms.utils' + +describe('engagement terms utils', () => { + const OLD_TERMS_ID = '317cd8f9-d66c-4f2a-8774-63c612d99cd4' + const NEW_TERMS_ID = '0a507fb7-3fe0-402b-b121-1a24af4a9cf1' + const NEW_NDA_TEMPLATE_ID = '400b989d-1c75-4889-b6f6-421e1f924709' + const TERMS_URL = `https://www.topcoder-dev.com/challenges/terms/detail/${OLD_TERMS_ID}` + + it('extracts the terms id from a terms detail URL', () => { + expect(extractTermId(TERMS_URL)) + .toBe(OLD_TERMS_ID) + }) + + it('rewrites a terms detail URL with the configured term id', () => { + expect(replaceTermIdInUrl(TERMS_URL, NEW_TERMS_ID)) + .toBe(`https://www.topcoder-dev.com/challenges/terms/detail/${NEW_TERMS_ID}`) + }) + + it('prefers the configured Standard Terms id over the fallback terms URL id', () => { + expect(resolveStandardTermsConfig(NEW_TERMS_ID, TERMS_URL)) + .toEqual({ + id: NEW_TERMS_ID, + url: `https://www.topcoder-dev.com/challenges/terms/detail/${NEW_TERMS_ID}`, + }) + }) + + it('falls back to the terms URL id when no configured Standard Terms id is provided', () => { + expect(resolveStandardTermsConfig(undefined, TERMS_URL)) + .toEqual({ + id: OLD_TERMS_ID, + url: TERMS_URL, + }) + }) + + it('prefers the configured DocuSign template for NDA terms', () => { + expect(resolveDocuSignTemplateId({ + docusignTemplateId: 'old-template-id', + title: 'Topcoder Member Non-Disclosure Agreement v3.0', + }, NEW_NDA_TEMPLATE_ID)) + .toBe(NEW_NDA_TEMPLATE_ID) + }) + + it('keeps the Terms API DocuSign template for non-NDA terms', () => { + expect(resolveDocuSignTemplateId({ + docusignTemplateId: 'assignment-template-id', + title: 'Assignment Terms', + }, NEW_NDA_TEMPLATE_ID)) + .toBe('assignment-template-id') + }) +}) diff --git a/src/apps/engagements/src/lib/utils/terms.utils.ts b/src/apps/engagements/src/lib/utils/terms.utils.ts index e2232329b..499ac3d36 100644 --- a/src/apps/engagements/src/lib/utils/terms.utils.ts +++ b/src/apps/engagements/src/lib/utils/terms.utils.ts @@ -1,3 +1,21 @@ +export type ResolvedTermsConfig = { + id?: string + url?: string +} + +type TermsTemplateDetails = { + docusignTemplateId?: string | number + title?: string +} + +const NDA_TITLE_PATTERN = /\bnda\b|non[-\s]?disclosure/i + +/** + * Extracts the trailing terms identifier from a terms detail URL or path. + * + * @param termsUrl - Full terms URL, relative path, or plain slash-delimited terms path. + * @returns The trailing terms identifier, or undefined when the input is empty. + */ export const extractTermId = (termsUrl?: string): string | undefined => { if (!termsUrl) { return undefined @@ -19,3 +37,98 @@ export const extractTermId = (termsUrl?: string): string | undefined => { return parts[parts.length - 1] } } + +/** + * Rewrites an existing terms detail URL so it points at the supplied terms identifier. + * + * @param termsUrl - Current terms detail URL or path used as the URL template. + * @param termId - Terms identifier that should replace the trailing URL segment. + * @returns A terms detail URL for the supplied identifier, or the original URL when it cannot be rewritten. + */ +export const replaceTermIdInUrl = ( + termsUrl?: string, + termId?: string, +): string | undefined => { + const trimmedTermId = termId?.trim() + if (!termsUrl || !trimmedTermId) { + return termsUrl + } + + const trimmedTermsUrl = termsUrl.trim() + if (!trimmedTermsUrl) { + return undefined + } + + try { + const parsed = new URL(trimmedTermsUrl) + const parts = parsed.pathname.split('/') + .filter(Boolean) + + if (parts.length === 0) { + return trimmedTermsUrl + } + + parts[parts.length - 1] = trimmedTermId + parsed.pathname = `/${parts.join('/')}` + return parsed.toString() + } catch { + const parts = trimmedTermsUrl.split('/') + .filter(Boolean) + + if (parts.length === 0) { + return trimmedTermsUrl + } + + parts[parts.length - 1] = trimmedTermId + return parts.join('/') + } +} + +/** + * Resolves the Standard Terms ID and detail URL used by engagement agreement checks. + * + * @param defaultStandardTermsId - Preferred Standard Terms UUID from environment configuration. + * @param termsUrl - Fallback Standard Terms detail URL. + * @returns The resolved Standard Terms identifier and a matching detail URL. + */ +export const resolveStandardTermsConfig = ( + defaultStandardTermsId?: string, + termsUrl?: string, +): ResolvedTermsConfig => { + const id = defaultStandardTermsId?.trim() || extractTermId(termsUrl) + + return { + id, + url: replaceTermIdInUrl(termsUrl, id), + } +} + +/** + * Checks whether a terms record represents an NDA-style agreement. + * + * @param term - Terms API details or search result payload. + * @returns true when the term title is NDA/non-disclosure related. + */ +export const isNdaTerm = (term?: TermsTemplateDetails): boolean => ( + NDA_TITLE_PATTERN.test(term?.title ?? '') +) + +/** + * Resolves the DocuSign template id for a terms record. + * + * @param term - Terms API details or search result payload. + * @param configuredNdaTemplateId - Preferred DocuSign template id for NDA terms. + * @returns The configured NDA template id for NDA terms, otherwise the Terms API template id. + */ +export const resolveDocuSignTemplateId = ( + term?: TermsTemplateDetails, + configuredNdaTemplateId?: string, +): string | number | undefined => { + const normalizedConfiguredNdaTemplateId = configuredNdaTemplateId?.trim() + + if (normalizedConfiguredNdaTemplateId && isNdaTerm(term)) { + return normalizedConfiguredNdaTemplateId + } + + return term?.docusignTemplateId +} diff --git a/src/apps/engagements/src/pages/application-form/ApplicationFormPage.tsx b/src/apps/engagements/src/pages/application-form/ApplicationFormPage.tsx index 89e5459bf..383314c61 100644 --- a/src/apps/engagements/src/pages/application-form/ApplicationFormPage.tsx +++ b/src/apps/engagements/src/pages/application-form/ApplicationFormPage.tsx @@ -19,7 +19,7 @@ import { getUserDataForApplication, updateUserDataForApplication, } from '../../lib/services' -import { extractTermId } from '../../lib/utils' +import { extractTermId, resolveStandardTermsConfig } from '../../lib/utils' import { rootRoute } from '../../engagements.routes' import type { ApplicationFormData, PrePopulatedUserData } from './application-form.types' @@ -50,9 +50,14 @@ const getIsSubmitDisabled = (params: SubmitDisabledParams): boolean => ( || (params.hasSubmitted && !params.isValid) ) +const STANDARD_TERMS = resolveStandardTermsConfig( + EnvironmentConfig.DEFAULT_STANDARD_TERMS_UUID, + EnvironmentConfig.TERMS_URL, +) + const TERMS_STATUS_CONFIG: TermsStatusConfig[] = [ { - id: extractTermId(EnvironmentConfig.TERMS_URL) ?? '', + id: STANDARD_TERMS.id ?? '', label: 'Standard Topcoder Terms', }, { 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..4f847c2f5 --- /dev/null +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.module.scss @@ -0,0 +1,117 @@ +@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-barlow; + font-size: 22px; + font-weight: $font-weight-bold; + line-height: 28px; + margin: 0; + padding-right: $sp-8; + text-transform: none; +} + +.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, +.pointsHeader, +.points { + white-space: nowrap; +} + +.pointsHeader { + text-align: right; +} + +.points { + font-weight: $font-weight-normal; + text-align: right; +} diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.spec.tsx b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.spec.tsx new file mode 100644 index 000000000..64da257d3 --- /dev/null +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberChallengePointsModal.spec.tsx @@ -0,0 +1,60 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import type { PropsWithChildren, ReactNode } from 'react' +import { render, screen, within } from '@testing-library/react' + +import MemberChallengePointsModal from './MemberChallengePointsModal' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + URLS: { + CHALLENGES_PAGE: 'https://www.topcoder.com/challenges', + }, + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/ui', () => ({ + BaseModal: (props: PropsWithChildren<{ title?: ReactNode }>): JSX.Element => ( +
+ {props.title} +
{props.children}
+
+ ), +}), { + virtual: true, +}) + +describe('MemberChallengePointsModal', () => { + it('renders the points breakdown title and aligned points column hooks', () => { + render( + , + ) + + expect(screen.getByRole('heading', { name: 'Points Breakdown' })) + .toBeInTheDocument() + + const tableHeader = screen.getByText('Place') + .parentElement as HTMLElement + + expect(within(tableHeader) + .getByText('Points')) + .toHaveClass('pointsHeader') + expect(screen.getByText('1,000')) + .toHaveClass('points') + }) +}) 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..978b5bd84 --- /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..c5edf8e15 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: 16px; + line-height: 22px; + + @include ltesm { + flex-wrap: wrap; + min-height: 86px; + padding: $sp-4; + } +} + +.challengePointsLabel { + font-weight: $font-weight-normal; +} + +.challengePointsValue { + font-family: $font-barlow-condensed; + font-size: 26px; + font-weight: $font-weight-medium; + line-height: 28px; +} + +.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: 24px; + 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: 16px; + line-height: 24px; + } } .innerWrapper { @@ -41,68 +116,76 @@ } .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: auto 0; + margin: 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; + display: grid; align-items: center; - > svg { - flex: 0 0 auto; - } + justify-content: end; + grid-template-columns: $sp-6 $sp-3 minmax(58px, max-content) $sp-4 $sp-4; + min-width: 136px; } .rightArrowIcon { - margin-left: $sp-3; + color: rgba($tc-white, 0.9); + grid-column: 5; +} + +.winnerIcon { + color: #F2C900; + grid-column: 1; } .trackStats { display: flex; flex-direction: column; + grid-column: 3; 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; + line-height: 28px; } + .label { font-family: $font-roboto; font-weight: $font-weight-medium; font-size: 11px; - line-height: 12px; + line-height: 14px; + color: rgba($tc-white, 0.82); } } @@ -112,4 +195,79 @@ background: currentColor; border-radius: 50%; + grid-column: 1; +} + +.trackName { + min-width: 0; + padding-right: $sp-2; + overflow: hidden; + font-family: $font-roboto; + font-size: 16px; + line-height: 22px; + text-overflow: ellipsis; + white-space: nowrap; +} + +@container (max-width: 420px) { + .container { + padding: $sp-4; + + :global(.body-large-bold) { + font-size: 24px; + line-height: 32px; + } + } + + .sectionTitle { + margin-bottom: $sp-3; + } + + .footerNote { + :global(.body-main) { + font-size: 16px; + line-height: 24px; + } + } + + .trackListItem { + min-height: 50px; + padding: $sp-2 $sp-3; + } + + .trackDetails { + grid-template-columns: $sp-4 $sp-1 minmax(42px, max-content) $sp-1 $sp-3; + min-width: 96px; + } + + .trackStats { + min-width: 42px; + + .count { + font-size: 26px; + line-height: 28px; + } + + .label { + font-size: 11px; + line-height: 14px; + } + } + + .trackName { + font-size: 16px; + line-height: 22px; + } + + .icon { + @include icon-lg; + } + + .winnerIcon { + @include icon-lg; + } + + .rightArrowIcon { + @include icon-md; + } } diff --git a/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.spec.tsx b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.spec.tsx new file mode 100644 index 000000000..ce223a340 --- /dev/null +++ b/src/apps/profiles/src/components/tc-achievements/MemberStatsBlock/MemberStatsBlock.spec.tsx @@ -0,0 +1,114 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { readFileSync } from 'fs' +import type { PropsWithChildren } from 'react' +import { render, screen, within } from '@testing-library/react' + +import type { UserProfile } from '~/libs/core' + +import { MemberChallengePointsBar } from './MemberStatsBlock' + +const memberStatsBlockStyles = readFileSync(`${__dirname}/MemberStatsBlock.module.scss`, 'utf8') +const trackDetailsGridTemplatePattern = [ + '\\.trackDetails \\{[\\s\\S]*?grid-template-columns: ', + '\\$sp-6 \\$sp-3 minmax\\(58px, max-content\\) \\$sp-4 \\$sp-4;', +].join('') + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn(() => '#000000'), + useMemberStats: jest.fn(), +}), { + virtual: true, +}) + +jest.mock('~/libs/ui', () => ({ + BaseModal: (props: PropsWithChildren): JSX.Element =>
{props.children}
, + IconOutline: { + ChevronRightIcon: (props: { className?: string }): JSX.Element => ( + + ), + }, +}), { + virtual: true, +}) + +jest.mock('../../../member-profile/MemberProfile.context', () => ({ + useMemberProfileContext: jest.fn(() => ({ + statsRoute: jest.fn(), + })), +})) + +jest.mock('./MemberChallengePointsModal', () => jest.fn(() => '')) + +jest.mock('../../../lib', () => ({ + formatPlural: (count: number, label: string): string => `${label}${count === 1 ? '' : 's'}`, + WinnerIcon: (props: { className?: string }): JSX.Element => , +})) + +describe('MemberChallengePointsBar', () => { + it('renders the breakdown chevron with the larger icon size', () => { + render( + , + ) + + const breakdownButton = screen.getByRole('button', { + name: /view breakdown/i, + }) + const chevron = within(breakdownButton) + .getByTestId('breakdown-chevron') + + expect(chevron) + .toHaveClass('icon-lg') + expect(chevron) + .not + .toHaveClass('icon-sm') + }) +}) + +describe('MemberStatsBlock typography styles', () => { + it('uses the PM-5398 font sizes for the member stats section', () => { + expect(memberStatsBlockStyles) + .toMatch(/:global\(\.body-large-bold\) \{\s*font-size: 24px;/) + expect(memberStatsBlockStyles) + .toMatch(/:global\(\.body-main\) \{\s*font-size: 16px;/) + expect(memberStatsBlockStyles) + .toMatch(/\.count \{[\s\S]*?font-size: 26px;/) + expect(memberStatsBlockStyles) + .toMatch(/\.label \{[\s\S]*?font-size: 11px;/) + expect(memberStatsBlockStyles) + .toMatch(/\.trackName \{[\s\S]*?font-size: 16px;/) + }) + + it('reserves track details columns so member stats chevrons line up', () => { + expect(memberStatsBlockStyles) + .toMatch(/\.trackDetails \{[\s\S]*?display: grid;/) + expect(memberStatsBlockStyles) + .toMatch(new RegExp(trackDetailsGridTemplatePattern)) + expect(memberStatsBlockStyles) + .toMatch(/\.trackStats \{[\s\S]*?grid-column: 3;/) + expect(memberStatsBlockStyles) + .toMatch(/\.rightArrowIcon \{[\s\S]*?grid-column: 5;/) + expect(memberStatsBlockStyles) + .toMatch(/\.icon \{[\s\S]*?grid-column: 1;/) + expect(memberStatsBlockStyles) + .toMatch(/\.winnerIcon \{[\s\S]*?grid-column: 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..eb6e2bad1 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/components/tc-achievements/StatsDetailsLayout/StatsDetailsLayout.tsx b/src/apps/profiles/src/components/tc-achievements/StatsDetailsLayout/StatsDetailsLayout.tsx index 1d89f258c..fd87b6773 100644 --- a/src/apps/profiles/src/components/tc-achievements/StatsDetailsLayout/StatsDetailsLayout.tsx +++ b/src/apps/profiles/src/components/tc-achievements/StatsDetailsLayout/StatsDetailsLayout.tsx @@ -5,7 +5,7 @@ import { MemberStats } from '~/libs/core' import { StatsNavHeader } from '../StatsNavHeader' import { StatsSummaryBlock } from '../StatsSummaryBlock' -import { getSubTrackSubmissionCount, MemberStatsTrack } from '../../../hooks/useFetchActiveTracks' +import { getSubTrackDisplaySubmissionCount, MemberStatsTrack } from '../../../hooks/useFetchActiveTracks' import styles from './StatsDetailsLayout.module.scss' @@ -35,7 +35,7 @@ const StatsDetailsLayout: FC = props => ( challenges={props.trackData.challenges} wins={props.trackData.wins} submissions={ - getSubTrackSubmissionCount(props.trackData as MemberStats) + getSubTrackDisplaySubmissionCount(props.trackData as MemberStats) ?? (typeof props.trackData.submissions === 'number' ? props.trackData.submissions : undefined) } ranking={(props.trackData as MemberStats).rank?.rank} diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx index 1023542d2..9c2396887 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx @@ -1,6 +1,12 @@ -import type { UserStats } from '~/libs/core' +import type { MemberStats, UserStats } from '~/libs/core' -import { getActiveTracks, MemberStatsTrack } from './useFetchActiveTracks' +import { + getActiveTracks, + getMemberChallengePoints, + getSubTrackDisplaySubmissionCount, + getSubTrackSummaryStats, + MemberStatsTrack, +} from './useFetchActiveTracks' jest.mock('~/libs/core', () => ({ useMemberStats: jest.fn(), @@ -74,6 +80,57 @@ describe('getActiveTracks', () => { .toEqual(['Challenge', 'Task']) }) + it('includes Data Science Challenge stats in the Data Science track', () => { + const activeTracks: MemberStatsTrack[] = getActiveTracks({ + DATA_SCIENCE: { + Challenge: { + challenges: 1, + rank: { + percentile: 10, + rating: 1499, + }, + submissions: { + submissions: 0, + }, + wins: 1, + }, + MARATHON_MATCH: { + challenges: 1, + rank: { + percentile: 20, + rating: 763, + }, + wins: 0, + }, + }, + } as UserStats) + const dataScienceTrack: MemberStatsTrack | undefined = activeTracks + .find(track => track.name === 'Data Science') + const developmentTrack: MemberStatsTrack | undefined = activeTracks + .find(track => track.name === 'Development') + const challengeSubTrack = dataScienceTrack?.subTracks + .find(track => track.name === 'Challenge') + + expect(dataScienceTrack?.challenges) + .toEqual(2) + expect(dataScienceTrack?.wins) + .toEqual(1) + expect(dataScienceTrack?.rating) + .toEqual(1499) + expect(dataScienceTrack?.percentile) + .toEqual(10) + expect(dataScienceTrack?.subTracks.map(track => track.name)) + .toEqual(['Challenge', 'MARATHON_MATCH']) + expect(challengeSubTrack) + .toEqual(expect.objectContaining({ + name: 'Challenge', + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', + })) + expect(developmentTrack) + .toBeUndefined() + }) + it('keeps legacy testing subtracks in the testing track', () => { const activeTracks: MemberStatsTrack[] = getActiveTracks({ DEVELOP: { @@ -111,4 +168,271 @@ describe('getActiveTracks', () => { expect(testingTrackNames) .toEqual(['BUG_HUNT']) }) + + it('shows QA Challenge stats in the testing track', () => { + const activeTracks: MemberStatsTrack[] = getActiveTracks({ + QA: { + challenges: 1, + mostRecentEventDate: 1781237773026, + mostRecentSubmission: 1781237773026, + subTracks: [ + { + challenges: 1, + name: 'Challenge', + rank: { + rating: 1490, + }, + submissions: { + submissions: 1, + }, + wins: 1, + }, + ], + wins: 1, + }, + } as UserStats) + const testingTrack: MemberStatsTrack | undefined = activeTracks + .find(track => track.name === 'Testing') + const qaChallengeSubTrack = testingTrack?.subTracks + .find(track => track.name === 'Challenge') + + expect(testingTrack) + .toEqual(expect.objectContaining({ + challenges: 1, + wins: 1, + })) + expect(qaChallengeSubTrack) + .toEqual(expect.objectContaining({ + name: 'Challenge', + parentTrack: 'QA', + path: 'QA.subTracks', + })) + expect(qaChallengeSubTrack?.rank?.rating) + .toEqual(1490) + }) + + it('shows top-level AI engineering stats under the Development track', () => { + const memberStats = { + AI_ENGINEERING: { + challenges: 14, + rank: { + overallPercentile: 15, + rating: 101, + }, + submissions: { + submissions: 100, + }, + }, + challengePoints: 2847, + } as UserStats + const activeTracks: MemberStatsTrack[] = getActiveTracks(memberStats) + const developmentTrack: MemberStatsTrack | undefined = activeTracks + .find(track => track.name === 'Development') + const aiSubTrack = developmentTrack?.subTracks + .find(track => track.name === 'AI Engineering') + + expect(developmentTrack) + .toEqual(expect.objectContaining({ + challenges: 14, + isActive: true, + submissions: 100, + })) + expect(aiSubTrack) + .toEqual(expect.objectContaining({ + challenges: 14, + name: 'AI Engineering', + parentTrack: 'AI_ENGINEERING', + path: 'AI_ENGINEERING', + rank: expect.objectContaining({ + overallPercentile: 15, + rating: 101, + }), + submissions: { + submissions: 100, + }, + })) + expect(activeTracks.map(track => track.name)) + .not.toContain('AI Engineering') + expect(getMemberChallengePoints(memberStats)) + .toBe(2847) + }) + + it('shows Data Science AI rating paths under Development while keeping original Data Science Challenge', () => { + const activeTracks: MemberStatsTrack[] = getActiveTracks({ + challenges: 6, + DATA_SCIENCE: { + 'AI Engineering': { + challenges: 3, + rank: { + overallPercentile: 12, + rating: 1422, + }, + wins: 1, + }, + Challenge: { + challenges: 3, + rank: { + percentile: 18, + rating: 1380, + }, + wins: 1, + }, + }, + groupId: 1, + handle: 'winterflame', + handleLower: 'winterflame', + userId: 15391415, + wins: 2, + } as unknown as UserStats) + const developmentTrack: MemberStatsTrack | undefined = activeTracks + .find(track => track.name === 'Development') + const dataScienceTrack: MemberStatsTrack | undefined = activeTracks + .find(track => track.name === 'Data Science') + const aiSubTrack = developmentTrack?.subTracks + .find(track => track.name === 'AI Engineering') + const challengeSubTrack = dataScienceTrack?.subTracks + .find(track => track.name === 'Challenge') + + expect(developmentTrack) + .toEqual(expect.objectContaining({ + challenges: 3, + isActive: true, + submissions: 3, + wins: 1, + })) + expect(aiSubTrack) + .toEqual(expect.objectContaining({ + challenges: 3, + name: 'AI Engineering', + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', + rank: expect.objectContaining({ + overallPercentile: 12, + rating: 1422, + }), + wins: 1, + })) + expect(getSubTrackDisplaySubmissionCount(aiSubTrack)) + .toEqual(3) + expect(dataScienceTrack) + .toEqual(expect.objectContaining({ + challenges: 3, + isActive: true, + rating: 1380, + wins: 1, + })) + expect(challengeSubTrack) + .toEqual(expect.objectContaining({ + name: 'Challenge', + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', + })) + expect(activeTracks.map(track => track.name)) + .not.toContain('AI Engineering') + }) + + it('keeps rated custom non-AI data science paths visible as member stats tracks', () => { + const activeTracks: MemberStatsTrack[] = getActiveTracks({ + challenges: 5, + DATA_SCIENCE: { + 'Java MySQL': { + 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 unknown as UserStats) + const javaMySQLTrack: MemberStatsTrack | undefined = activeTracks.find(track => track.name === 'Java MySQL') + + expect(javaMySQLTrack) + .toEqual(expect.objectContaining({ + challenges: 3, + isActive: true, + isDSTrack: true, + percentile: 12, + rating: 1422, + submissions: 3, + wins: 1, + })) + expect(javaMySQLTrack?.subTracks) + .toEqual([ + expect.objectContaining({ + name: 'Java MySQL', + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', + }), + ]) + expect(activeTracks.map(track => track.name)) + .not.toContain('NO_RATING') + }) +}) + +describe('getSubTrackSummaryStats', () => { + it('falls back to challenge count when a subtrack has zero submissions', () => { + const summaryStats = getSubTrackSummaryStats({ + challenges: 3, + submissions: { + submissions: 0, + }, + wins: 3, + } as MemberStats) + + expect(summaryStats) + .toEqual({ + submissions: 3, + wins: 3, + }) + }) + + it('falls back to history placements for AI Engineering wins and submissions', () => { + const summaryStats = getSubTrackSummaryStats({ + challenges: 6, + name: 'AI Engineering', + submissions: { + submissions: 0, + }, + wins: 0, + } as MemberStats, [ + { + challengeId: 'ai-1', + challengeName: 'AI Challenge 1', + newRating: 840, + placement: 1, + ratingDate: 1781237773026, + }, + { + challengeId: 'ai-2', + challengeName: 'AI Challenge 2', + newRating: 860, + placement: 2, + ratingDate: 1781237773027, + }, + { + challengeId: 'ai-3', + challengeName: 'AI Challenge 3', + newRating: 901, + placement: 1, + ratingDate: 1781237773028, + }, + ]) + + expect(summaryStats) + .toEqual({ + submissions: 6, + wins: 2, + }) + }) }) diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx index 34628707b..fa8f5e2f7 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx @@ -1,7 +1,15 @@ import { useMemo } from 'react' import { filter, find, get, orderBy } from 'lodash' -import { MemberStats, SRMStats, useMemberStats, UserStats } from '~/libs/core' +import { + DataScienceRatingPathStats, + MemberStats, + MemberStatsGroup, + SRMStats, + StatsHistory, + useMemberStats, + UserStats, +} from '~/libs/core' import { calcProportionalAverage } from '../lib/math.utils' @@ -11,6 +19,25 @@ const testingSubTrackNames = new Set([ 'TEST_SUITES', ]) +const nativeDataScienceStatsKeys = new Set([ + 'Challenge', + 'MARATHON_MATCH', + 'SRM', + 'challenges', + 'mostRecentEventDate', + 'mostRecentEventName', + 'mostRecentSubmission', + 'wins', +]) + +const AI_ENGINEERING_DISPLAY_NAME = 'AI Engineering' + +const aiEngineeringRatingPathNames = new Set([ + 'AI', + 'AI_ENGINEER', + 'AI_ENGINEERING', +]) + /** * The structure of a track for a member. */ @@ -24,12 +51,22 @@ export interface MemberStatsTrack { percentile?: number, submissionRate?: number screeningSuccessRate?: number + challengePoints?: number wins: number, order?: number isDSTrack?: boolean isCPTrack?: boolean } +export interface SubTrackSummaryStats { + submissions: number + wins: number +} + +const getFiniteNumber = (value: unknown): number | undefined => ( + typeof value === 'number' && Number.isFinite(value) ? value : undefined +) + /** * Return the explicit submission count when the stats payload includes one. * @@ -44,20 +81,71 @@ export const getSubTrackSubmissionCount = (subTrack?: MemberStats): number | und return typeof submissionCount === 'number' ? submissionCount : undefined } +/** + * Return the submission count to display in the profile stats UI. + * + * Unified Data Science and configured rating-path rows can omit explicit + * submission counters or leave them at zero even though their challenge count + * is sourced from valid submissions. In that case, use challenge participation + * count so the profile does not display impossible zero-submission wins. + * + * @param {MemberStats | undefined} subTrack - The subtrack to inspect. + * @returns {number | undefined} Explicit submissions, or challenge count as a fallback. + */ +export const getSubTrackDisplaySubmissionCount = (subTrack?: MemberStats): number | undefined => { + const submissionCount = getSubTrackSubmissionCount(subTrack) + + if (submissionCount !== undefined && submissionCount > 0) { + return submissionCount + } + + const challengeCount = getFiniteNumber(subTrack?.challenges) + + return challengeCount !== undefined && challengeCount > 0 + ? challengeCount + : submissionCount +} + +/** + * Builds the displayed win/submission counts for a subtrack card or summary. + * + * Some unified stats rows currently include challenge/rating history while the + * aggregate win or submission counters are omitted or left at zero. In that + * case, history placements are used for wins and history/challenge count is + * used as the minimum visible submission count. + * + * @param {MemberStats | undefined} subTrack - The subtrack to summarize. + * @param {StatsHistory[]} trackHistory - Optional history rows for the same subtrack. + * @returns {SubTrackSummaryStats} Display-safe win and submission counts. + */ +export const getSubTrackSummaryStats = ( + subTrack?: MemberStats, + trackHistory: StatsHistory[] = [], +): SubTrackSummaryStats => { + const statWins = getFiniteNumber(subTrack?.wins) ?? 0 + const historyWins = trackHistory.filter(history => history.placement === 1).length + const displaySubmissions = getSubTrackDisplaySubmissionCount(subTrack) ?? 0 + const historySubmissions = trackHistory.length + + return { + submissions: Math.max(displaySubmissions, historySubmissions), + wins: statWins > 0 ? statWins : historyWins, + } +} + /** * Determine whether the subtrack should be considered active. * - * Unified member stats do not currently include legacy submission counters for - * development/design rows, so fall back to the challenge count when the - * submission count is unavailable. + * Some rated rows can have zero submissions while still having challenge + * history, so challenge count also keeps the subtrack visible. * * @param {MemberStats | undefined} subTrack - The subtrack to inspect. * @returns {boolean} Whether the subtrack has activity worth rendering. */ const isActiveSubTrack = (subTrack?: MemberStats): boolean => { - const submissionCount = getSubTrackSubmissionCount(subTrack) + const submissionCount = getSubTrackDisplaySubmissionCount(subTrack) - return (submissionCount ?? subTrack?.challenges ?? 0) > 0 + return (submissionCount ?? 0) > 0 || (subTrack?.challenges ?? 0) > 0 } /** @@ -74,6 +162,48 @@ const isTestingSubTrack = (subTrack?: MemberStats): boolean => ( !!subTrack?.name && testingSubTrackNames.has(subTrack.name) ) +/** + * Pick the Data Science subtrack rating used by the summary card. + * + * Data Science can include Marathon Match and Challenge ratings. The summary + * should show the strongest visible rating instead of always using Marathon + * Match, otherwise Data Science Challenge ratings are hidden from the profile. + * + * @param {MemberStats[]} subTracks - Active Data Science subtracks. + * @returns {MemberStats | undefined} The subtrack with the highest rating. + */ +const getDataScienceSummarySubTrack = (subTracks: MemberStats[]): MemberStats | undefined => orderBy( + subTracks, + [ + subTrack => subTrack.rank?.rating ?? 0, + subTrack => subTrack.rank?.percentile ?? 0, + subTrack => subTrack.challenges ?? 0, + ], + ['desc', 'desc', 'desc'], +)[0] + +/** + * Normalizes a track or rating path name for alias comparison. + * + * @param {string | undefined} value - Raw track, subtrack, or configured rating path name. + * @returns {string} Uppercase underscore-delimited token. + */ +const normalizeTrackToken = (value?: string): string => ( + value?.trim() + .toUpperCase() + .replace(/[\s-]+/g, '_') ?? '' +) + +/** + * Checks whether a configured rating path is one of the AI Engineering aliases. + * + * @param {string | undefined} ratingPathName - DATA_SCIENCE rating path key. + * @returns {boolean} Whether the path should render as Development > AI Engineering. + */ +const isAIEngineeringRatingPathName = (ratingPathName?: string): boolean => ( + aiEngineeringRatingPathNames.has(normalizeTrackToken(ratingPathName)) +) + /** * Attach parent track metadata to legacy design/develop subtracks and index them by name. * @@ -82,7 +212,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 +226,54 @@ const mapSubTracksByName = ( }, {} as {[key: string]: MemberStats}) ?? {} ) +/** + * 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. * @@ -109,9 +287,9 @@ const buildTrackData = (trackName: string, allSubTracks: MemberStats[]): MemberS const totalWins = subTracks.reduce((sum, subTrack) => (sum + (subTrack?.wins || 0)), 0) const challengesCount = subTracks.reduce((sum, subTrack) => (sum + (subTrack?.challenges || 0)), 0) const submissionsCount = subTracks.reduce((sum, subTrack) => ( - sum + (getSubTrackSubmissionCount(subTrack) ?? 0) + sum + (getSubTrackDisplaySubmissionCount(subTrack) ?? 0) ), 0) - const hasSubmissionCounts = subTracks.some(subTrack => getSubTrackSubmissionCount(subTrack) !== undefined) + const hasSubmissionCounts = subTracks.some(subTrack => getSubTrackDisplaySubmissionCount(subTrack) !== undefined) // Return aggregated track data return { @@ -149,6 +327,247 @@ const enhanceDesignTrackData = (trackData: MemberStatsTrack): MemberStatsTrack = } } +/** + * Converts a top-level AI payload into source subtracks for aggregation. + * + * @param {MemberStatsGroup | undefined} aiStats - Raw top-level AI stats payload. + * @returns {MemberStats[]} Source subtracks used to aggregate an AI Engineering row. + */ +const getTopLevelAIEngineeringSourceSubTracks = (aiStats?: MemberStatsGroup): MemberStats[] => { + if (!aiStats) { + return [] + } + + if (aiStats.subTracks?.length) { + return Object.values(mapSubTracksByName('AI_ENGINEERING', aiStats.subTracks)) + } + + return [{ + ...(aiStats as MemberStats), + name: AI_ENGINEERING_DISPLAY_NAME, + parentTrack: 'AI_ENGINEERING', + path: 'AI_ENGINEERING', + }] +} + +/** + * Checks whether a top-level AI payload has visible profile activity. + * + * @param {MemberStatsTrack} trackData - Aggregated AI source subtrack data. + * @param {number | undefined} rating - Current AI Engineering rating. + * @param {number | undefined} challenges - Current AI Engineering challenge count. + * @param {number | undefined} submissions - Current AI Engineering submission count. + * @param {number | undefined} wins - Current AI Engineering win count. + * @returns {boolean} Whether the payload should create a Development subtrack. + */ +const hasTopLevelAIEngineeringActivity = ( + trackData: MemberStatsTrack, + rating?: number, + challenges?: number, + submissions?: number, + wins?: number, +): boolean => ( + trackData.isActive + || !!rating + || !!challenges + || !!submissions + || !!wins +) + +/** + * Builds the rank object for a Development AI Engineering subtrack. + * + * @param {MemberStatsGroup} aiStats - Raw top-level AI stats payload. + * @param {number | undefined} rating - Current AI Engineering rating. + * @param {number | undefined} percentile - Current AI Engineering percentile. + * @returns {MemberStats['rank']} Rank fields for the display subtrack. + */ +const buildAIEngineeringRank = ( + aiStats: MemberStatsGroup, + rating?: number, + percentile?: number, +): MemberStats['rank'] => ({ + ...((aiStats as MemberStats).rank ?? {}), + ...(rating === undefined ? {} : { rating }), + ...(percentile === undefined ? {} : { overallPercentile: percentile }), +}) + +/** + * Builds the submissions field for a Development AI Engineering subtrack. + * + * @param {MemberStatsGroup} aiStats - Raw top-level AI stats payload. + * @param {number | undefined} submissions - Aggregated submission count. + * @returns {MemberStats['submissions']} Submission stats for the display subtrack. + */ +const buildAIEngineeringSubmissions = ( + aiStats: MemberStatsGroup, + submissions?: number, +): MemberStats['submissions'] => ( + submissions === undefined + ? (aiStats as MemberStats).submissions + : { submissions } +) + +/** + * Builds a Development subtrack from a top-level AI Engineering API payload. + * + * @param {UserStats | undefined} memberStats - The raw stats payload for the user. + * @returns {MemberStats | undefined} AI Engineering subtrack data when active stats exist. + */ +const buildTopLevelAIEngineeringSubTrack = (memberStats?: UserStats): MemberStats | undefined => { + const aiStats = getAIEngineeringStats(memberStats) + + if (!aiStats) { + return undefined + } + + const subTracks = getTopLevelAIEngineeringSourceSubTracks(aiStats) + const trackData = buildTrackData(AI_ENGINEERING_DISPLAY_NAME, subTracks) + const submissions = getSubTrackSubmissionCount(aiStats as MemberStats | undefined) ?? trackData.submissions + const challenges = getFiniteNumber(aiStats?.challenges) ?? trackData.challenges ?? 0 + const rating = getFiniteNumber(aiStats?.rank?.rating) + const wins = getFiniteNumber(aiStats?.wins) ?? trackData.wins + const percentile = getFiniteNumber(aiStats?.rank?.overallPercentile) + ?? getFiniteNumber(aiStats?.rank?.percentile) + + if (!hasTopLevelAIEngineeringActivity(trackData, rating, challenges, submissions, wins)) { + return undefined + } + + return { + ...(aiStats as MemberStats), + challenges, + name: AI_ENGINEERING_DISPLAY_NAME, + parentTrack: 'AI_ENGINEERING', + path: 'AI_ENGINEERING', + rank: buildAIEngineeringRank(aiStats, rating, percentile), + submissions: buildAIEngineeringSubmissions(aiStats, submissions), + wins, + } +} + +/** + * Builds a Development subtrack from a DATA_SCIENCE AI Engineering rating path. + * + * The API can return configured AI Engineering rows under DATA_SCIENCE while + * the profile hierarchy displays that rating path under Development. The + * returned subtrack keeps DATA_SCIENCE metadata so history and distribution + * calls still use the stored API dimension. + * + * @param {UserStats | undefined} memberStats - The raw stats payload for the user. + * @returns {MemberStats | undefined} AI Engineering subtrack data when the path is rated. + */ +const buildDataScienceAIEngineeringSubTrack = (memberStats?: UserStats): MemberStats | undefined => { + const dataScienceStats = memberStats?.DATA_SCIENCE + + if (!dataScienceStats) { + return undefined + } + + const candidates = Object.entries(dataScienceStats) + .reduce((subTracks: MemberStats[], [ratingPathName, ratingPathStats]) => { + if ( + nativeDataScienceStatsKeys.has(ratingPathName) + || !isAIEngineeringRatingPathName(ratingPathName) + || !isDataScienceRatingPathStats(ratingPathStats) + ) { + return subTracks + } + + subTracks.push({ + ...(ratingPathStats as MemberStats), + name: AI_ENGINEERING_DISPLAY_NAME, + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', + }) + + return subTracks + }, []) + + return getDataScienceSummarySubTrack(candidates) +} + +/** + * Returns the AI Engineering subtrack that should be grouped under Development. + * + * DATA_SCIENCE rating-path rows are preferred because they retain the native + * history/distribution path for existing API payloads. Top-level AI payloads + * are still supported for compatibility. + * + * @param {UserStats | undefined} memberStats - The raw stats payload for the user. + * @returns {MemberStats | undefined} Display-ready Development subtrack. + */ +const getAIEngineeringDevelopmentSubTrack = (memberStats?: UserStats): MemberStats | undefined => ( + buildDataScienceAIEngineeringSubTrack(memberStats) + ?? buildTopLevelAIEngineeringSubTrack(memberStats) +) + +/** + * Builds an active track from a configured DATA_SCIENCE rating path. + * + * 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), + submissions: getSubTrackDisplaySubmissionCount(subTrack), + 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) + || isAIEngineeringRatingPathName(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. * @@ -158,6 +577,13 @@ const enhanceDesignTrackData = (trackData: MemberStatsTrack): MemberStatsTrack = export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => { // Create mappings for data science subtracks const dataScienceSubTracks: {[key: string]: MemberStats | SRMStats} = { + // Map Challenge subtrack + Challenge: (memberStats?.DATA_SCIENCE?.Challenge && ({ + ...memberStats.DATA_SCIENCE.Challenge, + name: 'Challenge', + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', + })) as MemberStats, // Map MARATHON_MATCH subtrack MARATHON_MATCH: (memberStats?.DATA_SCIENCE?.MARATHON_MATCH && ({ ...memberStats.DATA_SCIENCE.MARATHON_MATCH, @@ -187,6 +613,11 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => memberStats?.DEVELOP?.subTracks, ) + const qaSubTracks: {[key: string]: MemberStats} = mapSubTracksByName( + 'QA', + memberStats?.QA?.subTracks, + ) + // Build aggregated stats for Design, Development, Testing, and Competitive Programming tracks // Design const designTrackStats: MemberStatsTrack = ( @@ -196,10 +627,19 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => ) // Development + const aiEngineeringDevelopmentSubTrack = getAIEngineeringDevelopmentSubTrack(memberStats) + const developSubTrackValues = Object.values(developSubTracks) + const hasDevelopmentAIEngineeringSubTrack = developSubTrackValues + .some(subTrack => isAIEngineeringRatingPathName(subTrack.name)) const developTrackStats: MemberStatsTrack = ( buildTrackData( 'Development', - Object.values(developSubTracks) + [ + ...developSubTrackValues, + ...(hasDevelopmentAIEngineeringSubTrack || !aiEngineeringDevelopmentSubTrack + ? [] + : [aiEngineeringDevelopmentSubTrack]), + ] .filter(subTrack => !isTestingSubTrack(subTrack)), ) ) @@ -208,26 +648,29 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => const testingTrackStats: MemberStatsTrack = ( buildTrackData( 'Testing', - Object.values(developSubTracks) - .filter(isTestingSubTrack), + [ + ...Object.values(developSubTracks) + .filter(isTestingSubTrack), + ...Object.values(qaSubTracks), + ], ) ) // Data science const dsSubTracks: MemberStats[] = [ + dataScienceSubTracks.Challenge, dataScienceSubTracks.MARATHON_MATCH, ].filter(d => d?.challenges > 0) as MemberStats[] + const dsTrackData: MemberStatsTrack = buildTrackData('Data Science', dsSubTracks) + const dsSummarySubTrack: MemberStats | undefined = getDataScienceSummarySubTrack(dsTrackData.subTracks) + const dataScienceRatingPathTrackStats: MemberStatsTrack[] = getDataScienceRatingPathTrackData(memberStats) const dsTrackStats: MemberStatsTrack = { - challenges: dataScienceSubTracks.MARATHON_MATCH?.challenges ?? 0, - isActive: (dataScienceSubTracks.MARATHON_MATCH?.challenges ?? 0) > 0, + ...dsTrackData, isDSTrack: true, - name: 'Data Science', order: -1, - percentile: dataScienceSubTracks.MARATHON_MATCH?.rank?.percentile ?? 0, - rating: dataScienceSubTracks.MARATHON_MATCH?.rank?.rating ?? 0, - subTracks: dsSubTracks, - wins: dataScienceSubTracks.MARATHON_MATCH?.wins ?? 0, + percentile: dsSummarySubTrack?.rank?.percentile ?? 0, + rating: dsSummarySubTrack?.rank?.rating ?? 0, } // Competitive Programming @@ -255,6 +698,7 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => designTrackStats, developTrackStats, testingTrackStats, + ...dataScienceRatingPathTrackStats, ], { isActive: true }), ['order', 'wins', 'submissions'], ['desc', 'desc', 'desc']) } diff --git a/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx b/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx new file mode 100644 index 000000000..9bd01e75e --- /dev/null +++ b/src/apps/profiles/src/hooks/useTrackHistory.spec.tsx @@ -0,0 +1,119 @@ +import type { MemberStats, UserStatsHistory } from '~/libs/core' + +import { getTrackHistoryFromStats } from './useTrackHistory' + +jest.mock('~/libs/core', () => ({ + useStatsHistory: jest.fn(), +}), { + virtual: true, +}) + +describe('getTrackHistoryFromStats', () => { + it('reads keyed Data Science Challenge history', () => { + const trackData = { + name: 'Challenge', + parentTrack: 'DATA_SCIENCE', + path: 'DATA_SCIENCE', + } as MemberStats + const history = getTrackHistoryFromStats({ + DATA_SCIENCE: { + Challenge: { + history: [ + { + challengeId: 'ds-challenge', + challengeName: 'Data Science Challenge', + newRating: 1499, + ratingDate: 1781237773026, + }, + ], + }, + }, + groupId: 10, + handle: 'testcoun', + handleLower: 'testcoun', + userId: 89770325, + } as UserStatsHistory, trackData) + + expect(history) + .toEqual([ + expect.objectContaining({ + challengeId: 'ds-challenge', + newRating: 1499, + }), + ]) + }) + + it('reads QA subtrack history paths', () => { + const trackData = { + name: 'Challenge', + parentTrack: 'QA', + path: 'QA.subTracks', + } as MemberStats + const history = getTrackHistoryFromStats({ + groupId: 10, + handle: 'testmfa6', + handleLower: 'testmfa6', + QA: { + subTracks: [ + { + history: [ + { + challengeId: 'qa-challenge', + challengeName: 'QA Challenge', + newRating: 1490, + ratingDate: 1781237773026, + }, + ], + name: 'Challenge', + }, + ], + }, + userId: 89770374, + } as UserStatsHistory, trackData) + + expect(history) + .toEqual([ + expect.objectContaining({ + challengeId: 'qa-challenge', + newRating: 1490, + }), + ]) + }) + + it('reads AI Engineering challenge history from Data Science aliases', () => { + const trackData = { + name: 'AI Engineering', + parentTrack: 'DEVELOP', + path: 'DEVELOP.subTracks', + } as MemberStats + const history = getTrackHistoryFromStats({ + DATA_SCIENCE: { + AI: { + history: [ + { + challengeId: 'ai-challenge', + challengeName: 'AI Challenge', + newRating: 840, + ratingDate: 1781237773026, + }, + ], + }, + }, + DEVELOP: { + subTracks: [], + }, + groupId: 10, + handle: 'aimember', + handleLower: 'aimember', + userId: 89770399, + } as unknown as UserStatsHistory, trackData) + + expect(history) + .toEqual([ + expect.objectContaining({ + challengeId: 'ai-challenge', + newRating: 840, + }), + ]) + }) +}) diff --git a/src/apps/profiles/src/hooks/useTrackHistory.tsx b/src/apps/profiles/src/hooks/useTrackHistory.tsx index c20462fee..9e87c031e 100644 --- a/src/apps/profiles/src/hooks/useTrackHistory.tsx +++ b/src/apps/profiles/src/hooks/useTrackHistory.tsx @@ -2,28 +2,165 @@ import { find, get } from 'lodash' import { MemberStats, StatsHistory, UserStatsHistory, useStatsHistory } from '~/libs/core' +const AI_ENGINEERING_HISTORY_NAMES = [ + 'AI Engineering', + 'AI', + 'AI_ENGINEER', + 'AI_ENGINEERING', +] + +const AI_ENGINEERING_TRACK_TOKENS = new Set([ + 'AI', + 'AI_ENGINEER', + 'AI_ENGINEERING', +]) + +const AI_ENGINEERING_HISTORY_PATHS = [ + 'DATA_SCIENCE', + 'DEVELOP.subTracks', + 'AI_ENGINEERING', + 'AI', + 'AI_ENGINEER', +] + /** - * Fetches the user stats history and extracts the history data for the specified track - * @param userHandle - User's handle - * @param trackData - The track data for which we want to fetch the history - * @returns + * Normalizes a track or rating path name for alias comparison. + * + * @param {string | undefined} value - Raw track, subtrack, or configured rating path name. + * @returns {string} Uppercase underscore-delimited token. */ -export const useTrackHistory = (userHandle?: string, trackData?: MemberStats): StatsHistory[] => { - const statsHistory: UserStatsHistory | undefined = useStatsHistory(userHandle) +const normalizeTrackToken = (value?: string): string => ( + value?.trim() + .toUpperCase() + .replace(/[\s-]+/g, '_') ?? '' +) + +/** + * Checks whether a displayed stats subtrack represents AI Engineering. + * + * @param {MemberStats} trackData - Displayed subtrack data from the member stats payload. + * @returns {boolean} Whether the subtrack should use AI Engineering history aliases. + */ +const isAIEngineeringTrackData = (trackData: MemberStats): boolean => ( + AI_ENGINEERING_TRACK_TOKENS.has(normalizeTrackToken(trackData.name)) + || AI_ENGINEERING_TRACK_TOKENS.has(normalizeTrackToken(trackData.parentTrack)) +) + +/** + * Returns a history array only when it contains challenge rows. + * + * @param {StatsHistory[] | undefined} history - Candidate history rows. + * @returns {StatsHistory[] | undefined} Non-empty history rows, otherwise undefined. + */ +const getNonEmptyHistory = (history?: StatsHistory[]): StatsHistory[] | undefined => ( + Array.isArray(history) && history.length > 0 ? history : undefined +) + +/** + * Reads the first non-empty history array from keyed or subtrack-based paths. + * + * @param {UserStatsHistory | undefined} statsHistory - Raw stats-history payload. + * @param {string[]} paths - Candidate API paths that may contain history. + * @param {string[]} trackNames - Candidate subtrack or rating path names. + * @returns {StatsHistory[] | undefined} First matching history rows. + */ +const getFirstMatchingHistory = ( + statsHistory: UserStatsHistory | undefined, + paths: string[], + trackNames: string[], +): StatsHistory[] | undefined => { + for (const path of paths) { + const subTracks = get(statsHistory, path) + + if (Array.isArray(subTracks)) { + const history = getNonEmptyHistory( + find(subTracks, subTrack => trackNames.includes(subTrack.name))?.history, + ) + + if (history) { + return history + } + } + + for (const trackName of trackNames) { + const history = getNonEmptyHistory(get(statsHistory, `${path}.${trackName}.history`)) + + if (history) { + return history + } + } + } + return undefined +} + +/** + * Fetches the default history data for a track using its API path and name. + * + * @param {UserStatsHistory | undefined} statsHistory - Raw stats-history payload. + * @param {MemberStats} trackData - The track data for which to fetch history. + * @returns {StatsHistory[]} History rows for the displayed track. + */ +const getDefaultTrackHistory = ( + statsHistory: UserStatsHistory | undefined, + trackData: MemberStats, +): StatsHistory[] => ( + getFirstMatchingHistory(statsHistory, [trackData.path ?? ''], [trackData.name]) ?? [] +) + +/** + * Reads AI Engineering history across the API aliases used before and after + * the profile hierarchy moved AI Engineering under Development. + * + * @param {UserStatsHistory | undefined} statsHistory - Raw stats-history payload. + * @param {MemberStats} trackData - Displayed AI Engineering subtrack data. + * @returns {StatsHistory[]} AI Engineering challenge history rows. + */ +const getAIEngineeringTrackHistory = ( + statsHistory: UserStatsHistory | undefined, + trackData: MemberStats, +): StatsHistory[] => ( + getFirstMatchingHistory( + statsHistory, + [ + trackData.path, + ...AI_ENGINEERING_HISTORY_PATHS, + ].filter((path): path is string => !!path), + AI_ENGINEERING_HISTORY_NAMES, + ) ?? [] +) + +/** + * Extracts the history rows for a displayed track. + * + * @param {UserStatsHistory | undefined} statsHistory - Raw stats-history payload. + * @param {MemberStats | undefined} trackData - The track data for which to fetch history. + * @returns {StatsHistory[]} History rows for the displayed track. + */ +export const getTrackHistoryFromStats = ( + statsHistory: UserStatsHistory | undefined, + trackData?: MemberStats, +): StatsHistory[] => { if (!trackData) { return [] } - const trackHistory: StatsHistory[] = get( - find(get(statsHistory, `${trackData.path}`, []), { name: trackData.name }), - 'history', - ) - // marathon match has a different structure for the stats history - || get( - statsHistory, - `${trackData.path}.${trackData.name}.history`, - ) || [] + const defaultHistory = getDefaultTrackHistory(statsHistory, trackData) + + return defaultHistory.length > 0 || !isAIEngineeringTrackData(trackData) + ? defaultHistory + : getAIEngineeringTrackHistory(statsHistory, trackData) +} + +/** + * Fetches the user stats history and extracts the history data for the specified track. + * + * @param {string | undefined} userHandle - User's handle. + * @param {MemberStats | undefined} trackData - The track data for which to fetch history. + * @returns {StatsHistory[]} History rows for the specified track. + */ +export const useTrackHistory = (userHandle?: string, trackData?: MemberStats): StatsHistory[] => { + const statsHistory: UserStatsHistory | undefined = useStatsHistory(userHandle) - return trackHistory + return getTrackHistoryFromStats(statsHistory, trackData) } diff --git a/src/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..70d5bc349 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.module.scss @@ -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,134 @@ 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; + color: $tc-white; + 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 { + box-sizing: border-box; + grid-template-columns: 52px 104px minmax(0, 1fr); + column-gap: $sp-3; + justify-content: start; + justify-items: stretch; + max-width: 300px; + width: 100%; } } -} \ No newline at end of file +} + +.preferredRolesWrap { + align-items: flex-start; + color: $black-100; + display: flex; + justify-content: center; + margin: $sp-4 auto $sp-5; + max-width: 320px; + position: relative; + width: 100%; +} + +.preferredRolesList { + display: flex; + align-items: center; + flex-direction: column; + font-family: "Nunito Sans", sans-serif; + font-size: 14px; + font-weight: $font-weight-medium; + gap: $sp-1; + line-height: 20px; + text-align: center; +} + +.preferredRole { + display: block; + white-space: normal; +} + +.preferredRolesToggle { + color: $link-blue-dark; + font-family: "Nunito Sans", sans-serif; + font-size: 14px; + font-weight: $font-weight-medium; + line-height: 20px; + white-space: nowrap; + + &:hover { + color: darken($link-blue-dark, 5); + text-decoration: underline; + } +} + +.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.spec.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx new file mode 100644 index 000000000..0e0269bfc --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx @@ -0,0 +1,220 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import type { PropsWithChildren } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' + +import type { UserProfile, UserStats, UserStatsDistributionResponse } from '~/libs/core' +import { useMemberStats, useStatsDistribution } from '~/libs/core' + +import { getPreferredRolesText } from '../../../lib' + +import MemberRatingCard from './MemberRatingCard' + +const mockTooltip = jest.fn((props: PropsWithChildren<{ disableTooltip?: boolean }>) => <>{props.children}) + +jest.mock('~/libs/core', () => ({ + getRatingColor: jest.fn(() => '#616BD5'), + useMemberStats: jest.fn(), + useStatsDistribution: jest.fn(), +}), { + virtual: true, +}) + +jest.mock('~/libs/ui', () => ({ + Tooltip: (props: PropsWithChildren<{ disableTooltip?: boolean }>) => mockTooltip(props), +}), { + virtual: true, +}) + +jest.mock('../../../components', () => ({ + EditMemberPropertyBtn: () => , +})) + +jest.mock('../../../lib', () => ({ + getPreferredRolesText: jest.fn(() => ''), +})) + +jest.mock('./MemberRatingInfoModal', () => ({ + MemberRatingInfoModal: (props: { onClose: () => void }) => ( +
    + +
    + ), +})) + +jest.mock('./ModifyPreferredRolesModal', () => ({ + ModifyPreferredRolesModal: () =>
    , +})) + +const mockedUseMemberStats = useMemberStats as jest.MockedFunction +const mockedUseStatsDistribution = useStatsDistribution as jest.MockedFunction +const mockedGetPreferredRolesText = getPreferredRolesText as jest.MockedFunction +const profile = { handle: 'dave' } as UserProfile +const ratingDistribution: UserStatsDistributionResponse = { + createdAt: '2026-06-15T00:00:00.000Z', + createdBy: 'test', + distribution: { + ratingRange0To899: 10, + ratingRange900To1199: 20, + ratingRange1200To1499: 70, + }, + subTrack: 'MARATHON_MATCH', + track: 'DATA_SCIENCE', + updatedAt: '2026-06-15T00:00:00.000Z', + updatedBy: 'test', +} +const defaultProps = { + authProfile: undefined, + memberPersonalizationTraitsData: undefined, + mutatePersonalizationTraits: jest.fn(), + profile, +} + +/** + * Returns the props from the latest mocked Tooltip render. + * Used to verify the rating card disables its percentile tooltip while the rating modal is open. + * + * @returns The most recent Tooltip props captured by the mock. + */ +function getLastTooltipProps(): PropsWithChildren<{ disableTooltip?: boolean }> { + const lastTooltipCall = mockTooltip.mock.calls[mockTooltip.mock.calls.length - 1] + + return lastTooltipCall[0] +} + +describe('MemberRatingCard', () => { + beforeEach(() => { + jest.clearAllMocks() + mockTooltip.mockImplementation((props: PropsWithChildren<{ disableTooltip?: boolean }>) => ( + <>{props.children} + )) + mockedUseStatsDistribution.mockReturnValue(ratingDistribution) + mockedGetPreferredRolesText.mockReturnValue('') + }) + + it('shows preferred roles when rating data is unavailable', () => { + mockedUseMemberStats.mockReturnValue(undefined) + mockedGetPreferredRolesText.mockReturnValue('Designer\nFront-End Developer') + + render() + + expect(screen.queryByText('Rating')) + .not + .toBeInTheDocument() + expect(screen.queryByText('What is this?')) + .not + .toBeInTheDocument() + expect(screen.getByText('Designer')) + .toBeInTheDocument() + expect(screen.getByText('Front-End Developer')) + .toBeInTheDocument() + }) + + it('shows the preferred roles edit action for the profile owner when rating data is unavailable', () => { + mockedUseMemberStats.mockReturnValue(undefined) + + render() + + expect(screen.queryByText('Rating')) + .not + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Edit' })) + .toBeInTheDocument() + }) + + it('hides percentile details when the member rating is zero', () => { + mockedUseMemberStats.mockReturnValue({ + DATA_SCIENCE: { + MARATHON_MATCH: { + mostRecentEventDate: 1000, + rank: { + percentile: 100, + rating: 0, + }, + }, + }, + maxRating: { + rating: '0' as unknown as number, + }, + } as unknown as UserStats) + + render() + + expect(screen.getByText('0')) + .toBeInTheDocument() + expect(screen.getByText('Rating')) + .toBeInTheDocument() + expect(screen.queryByText('Top 100%')) + .not + .toBeInTheDocument() + expect(screen.queryByText('Data Scientists')) + .not + .toBeInTheDocument() + }) + + it('shows percentile details when the member has a positive rating', () => { + mockedUseMemberStats.mockReturnValue({ + DATA_SCIENCE: { + MARATHON_MATCH: { + mostRecentEventDate: 1000, + rank: { + percentile: 42, + rating: 1200, + }, + }, + }, + maxRating: { + rating: 1200, + }, + } as unknown as UserStats) + + render() + + expect(screen.getByText('1200')) + .toBeInTheDocument() + expect(screen.getByText('Top 70%')) + .toBeInTheDocument() + expect(screen.getByText('Top 70%')) + .not + .toHaveAttribute('style') + expect(screen.getByText('Data Scientists')) + .toBeInTheDocument() + }) + + it('disables the percentile tooltip while the rating modal is open', () => { + mockedUseMemberStats.mockReturnValue({ + DATA_SCIENCE: { + MARATHON_MATCH: { + mostRecentEventDate: 1000, + rank: { + percentile: 42, + rating: 1200, + }, + }, + }, + maxRating: { + rating: 1200, + }, + } as unknown as UserStats) + + render() + + expect(getLastTooltipProps().disableTooltip) + .toBe(false) + + fireEvent.click(screen.getByRole('button', { name: /Top 70% Data Scientists/i })) + + expect(screen.getByRole('dialog')) + .toBeInTheDocument() + expect(getLastTooltipProps().disableTooltip) + .toBe(true) + + fireEvent.click(screen.getByRole('button', { name: 'Close rating modal' })) + + expect(screen.queryByRole('dialog')) + .not + .toBeInTheDocument() + expect(getLastTooltipProps().disableTooltip) + .toBe(false) + }) +}) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx index 3ca7e39d0..c8e975356 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,87 @@ -/* 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 } from '../../../lib' + +import { + calculateTopPercentileFromDistribution, + formatTopPercentile, + getCompactRatingColor, + getPreferredRolesDisplay, + getProfileRating, + 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 } const MemberRatingCard: FC = (props: MemberRatingCardProps) => { const memberStats: UserStats | undefined = useMemberStats(props.profile.handle) + const rating: number | undefined = useMemo(() => getProfileRating(memberStats), [memberStats]) + const compactRatingColor: string = getCompactRatingColor(rating, getRatingColor(rating)) 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) - return memberPercentile - }, [memberStats]) + const ratingDistributionQuery = useMemo(() => getRatingDistributionQuery(memberStats), [memberStats]) + + const ratingDistribution: UserStatsDistributionResponse | undefined = useStatsDistribution(ratingDistributionQuery) + + const maxPercentile: number | undefined = useMemo(() => ( + calculateTopPercentileFromDistribution(ratingDistribution?.distribution, rating) + ), [rating, ratingDistribution]) + const audienceLabel: string = getRatingAudienceLabel(memberStats) + const hasPositiveRating: boolean = Number(rating) > 0 + const percentileLabel: string | undefined = hasPositiveRating && maxPercentile + ? `Top ${formatTopPercentile(maxPercentile)}%` + : undefined + const visiblePercentile: number | undefined = hasPositiveRating ? 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], + ) + const hasRating: boolean = rating !== undefined + const shouldRenderPreferredRoles: boolean = preferredRoles.length > 0 || canEditPreferredRoles + const shouldRenderCard: boolean = hasRating || shouldRenderPreferredRoles function handleInfoModalClose(): void { setIsInfoModalOpen(false) @@ -45,33 +91,129 @@ 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 shouldRenderCard ? (
    -
    -
    -

    {memberStats?.maxRating?.rating}

    -

    Rating

    -
    - { - maxPercentile ? ( -
    -

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

    -

    Percentile

    -
    - ) : undefined - } -
    + {hasRating && ( +
    + + { + percentileLabel ? ( + + {percentileLabel} + {' '} + of +
    + 2M + {' '} + {audienceLabel.toLowerCase()} + + )} + disableTooltip={isInfoModalOpen} + place='top' + > + +
    + ) : undefined + }
    -
    + )} { - isInfoModalOpen && ( + isInfoModalOpen && hasRating && ( + ) + } + + {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..abeda1c63 --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts @@ -0,0 +1,348 @@ +import type { UserStats, UserStatsDistributionResponse } from '~/libs/core' + +import { + calculateTopPercentileFromDistribution, + formatTopPercentile, + getCompactRatingColor, + getPreferredRolesDisplay, + getProfileRating, + getRatingAudienceLabel, + getRatingDistributionQuery, + parsePreferredRolesText, +} from './MemberRatingCard.utils' + +describe('getCompactRatingColor', () => { + it('uses the lighter grey value for the lowest rating tier on the dark compact card', () => { + expect(getCompactRatingColor(840, '#555555')) + .toBe('#7F7F7F') + }) + + it('keeps the shared rating colors for higher rating tiers', () => { + expect(getCompactRatingColor(900, '#2D7E2D')) + .toBe('#2D7E2D') + expect(getCompactRatingColor(1200, '#616BD5')) + .toBe('#616BD5') + expect(getCompactRatingColor(1500, '#F2C900')) + .toBe('#F2C900') + expect(getCompactRatingColor(2200, '#EF3A3A')) + .toBe('#EF3A3A') + }) +}) + +describe('getProfileRating', () => { + it('uses the highest rated track instead of the newest lower rating', () => { + expect(getProfileRating({ + DATA_SCIENCE: { + 'AI Engineering': { + mostRecentEventDate: 1000, + rank: { + rating: 1200, + }, + }, + Challenge: { + mostRecentEventDate: 2000, + rank: { + rating: 732, + }, + }, + }, + maxRating: { + rating: 1200, + ratingColor: '#616BD5', + subTrack: 'AI Engineering', + track: 'DATA_SCIENCE', + }, + } as unknown as UserStats)) + .toBe(1200) + }) + + it('uses configured data science rating paths when they are highest', () => { + expect(getProfileRating({ + DATA_SCIENCE: { + AI: { + 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(840) + }) + + it('falls back to maxRating when no rated track entries are available', () => { + expect(getProfileRating({ + 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('formatTopPercentile', () => { + it('shows top one percent for positive percentages that would round to zero', () => { + expect(formatTopPercentile(0.4)) + .toBe('1') + }) + + it('keeps normal whole-number rounding for visible top percentages', () => { + expect(formatTopPercentile(12.4)) + .toBe('12') + expect(formatTopPercentile(12.5)) + .toBe('13') + }) +}) + +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 highest 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('Data Scientists') + }) +}) + +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 highest 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: 'SRM', + track: 'DATA_SCIENCE', + }) + }) + + 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..3b028d28b --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts @@ -0,0 +1,463 @@ +import type { 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 lowestRatingTierLimit = 900 +const compactLowestRatingColor = '#7F7F7F' + +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 +} + +/** + * Formats the compact rating card's visible top percentage. + * + * @param {number} percentile - Top percentage calculated from the rating distribution. + * @returns {string} Rounded whole percentage, with positive values clamped to at least 1. + * @throws Does not throw. + */ +export const formatTopPercentile = (percentile: number): string => { + const roundedPercentile = Math.round(percentile) + + return `${percentile > 0 ? Math.max(roundedPercentile, 1) : roundedPercentile}` +} + +/** + * Returns the rating text color for the dark compact profile rating card. + * + * The shared grey rating color is too dark against the compact card background, + * so the lowest rating tier is mapped to the lighter grey palette value while + * all other rating tiers continue to use the shared Topcoder rating color passed in. + * Used by MemberRatingCard for the rating value and top-percentile badge. + * + * @param {number | undefined} rating - The profile rating rendered in the compact card. + * @param {string} ratingColor - Shared Topcoder rating color for the same rating. + * @returns {string} Hex color to use for compact card rating text. + * @throws Does not throw. + */ +export const getCompactRatingColor = (rating: number | undefined, ratingColor: string): string => ( + rating !== undefined && rating < lowestRatingTierLimit + ? compactLowestRatingColor + : ratingColor +) + +/** + * 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 a rating candidate from the API maxRating payload. + * + * @param {UserStats | undefined} memberStats - Raw member stats for the profile user. + * @returns {RatingCandidate | undefined} Max rating candidate when enough metadata is available. + */ +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 highest rated track candidate used by the compact rating card. + * + * @param {UserStats | undefined} memberStats - Raw member stats for the profile user. + * @returns {RatingCandidate | undefined} Highest current rating candidate, with event date as a tie-breaker. + */ +const getProfileRatingCandidate = (memberStats?: UserStats): RatingCandidate | undefined => { + const candidates: RatingCandidate[] = [ + getMaxRatingCandidate(memberStats), + ...getSubTrackRatingCandidates('DEVELOP', memberStats?.DEVELOP?.subTracks), + ...getSubTrackRatingCandidates('DESIGN', memberStats?.DESIGN?.subTracks), + ...getDataScienceRatingCandidates(memberStats?.DATA_SCIENCE), + ...getAIEngineeringRatingCandidates(memberStats), + ].filter((candidate: RatingCandidate | undefined): candidate is RatingCandidate => candidate !== undefined) + + return candidates.reduce(( + highest: RatingCandidate | undefined, + candidate: RatingCandidate, + ) => { + if ( + highest === undefined + || candidate.rating > highest.rating + || (candidate.rating === highest.rating && candidate.ratingDate > highest.ratingDate) + ) { + return candidate + } + + return highest + }, undefined) +} + +/** + * Returns the rating that should be shown on the compact profile rating card. + * + * The card should show the highest current rating from the user's rated tracks. + * The API `maxRating` payload participates in selection so the compact card + * stays aligned with the stats response, while rating event date only breaks ties. + * + * @param {UserStats | undefined} memberStats - Raw member stats for the profile user. + * @returns {number | undefined} Highest current rating when available. + */ +export const getProfileRating = (memberStats?: UserStats): number | undefined => ( + getProfileRatingCandidate(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 = getProfileRatingCandidate(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 = getProfileRatingCandidate(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..2c27fc0ec --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss @@ -0,0 +1,355 @@ +@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; + flex-direction: row; + flex-wrap: nowrap; + gap: $sp-4; + grid-column: 1 / -1; + justify-content: flex-start; + } +} + +.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 { + color: $black-100; + 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; +} + +.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; +} + +.memberMarkerStacked { + &::after { + top: $sp-14; + } + + .markerBadge { + flex-direction: column; + gap: $sp-1; + transform: translateX(0); + } +} + +.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..bb0206a0e --- /dev/null +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx @@ -0,0 +1,152 @@ +/* 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' + +const baseProfile = { + firstName: 'Emily', + handle: 'emily', + photoURL: 'https://example.com/photo.png', +} as UserProfile + +const ratingDistribution = { + createdAt: '2026-06-04T00:00:00.000Z', + createdBy: 'test', + distribution: { + ratingRange0To899: 10, + ratingRange900To1199: 20, + ratingRange1200To1499: 30, + ratingRange1500To2199: 40, + }, + subTrack: 'AI', + track: 'DATA_SCIENCE', + updatedAt: '2026-06-04T00:00:00.000Z', + updatedBy: 'test', +} + +const wideRatingDistribution = { + ...ratingDistribution, + distribution: { + ratingRange0To899: 10, + ratingRange900To1199: 20, + ratingRange1200To1499: 30, + ratingRange1500To2199: 40, + ratingRange2200To4499: 1, + }, +} + +const expandedTailRatingDistribution = { + ...ratingDistribution, + distribution: { + ratingRange0To899: 10, + ratingRange900To1199: 20, + ratingRange1200To1499: 30, + ratingRange1500To2199: 40, + ratingRange2200To2999: 5, + ratingRange3000To3999: 2, + ratingRange4000To4999: 0, + }, +} + +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('renders the position summary without the pyramid graphic', () => { + render( + , + ) + + const positionSummary = screen.getByTestId('rating-position-summary') + + expect(within(positionSummary) + .getByText('Position')) + .toBeInTheDocument() + expect(within(positionSummary) + .getByText(/TOP\s+15%/)) + .toBeInTheDocument() + expect(within(positionSummary) + .getByText(/TOP\s+15%/)) + .not + .toHaveAttribute('style') + expect(positionSummary.querySelector('svg')) + .not + .toBeInTheDocument() + expect(screen.getByText('Where Emily ranks in the distribution')) + .toBeInTheDocument() + }) + + it('shows a positive sub-one percentile as top one percent', () => { + render( + , + ) + + expect(screen.getByText(/TOP\s+1%/)) + .toBeInTheDocument() + expect(screen.queryByText(/TOP\s+0%/)) + .not + .toBeInTheDocument() + }) + + it('stacks the marker rating for extreme ratings before the right-edge threshold', () => { + render( + , + ) + + const marker = screen.getByTestId('rating-member-marker') + + expect(parseFloat(marker.style.left)) + .toBeLessThan(80) + expect(marker) + .toHaveClass('memberMarkerStacked') + }) +}) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx index 10fa2896c..7d9e6341c 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,436 @@ -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 { formatTopPercentile } from '../MemberRatingCard.utils' + +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. -

    -
    +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, +}] + +// The inline avatar and score need room to the right of the marker, so stack +// before the marker reaches the final segment of the chart. +const stackedMarkerPositionThreshold = 80 +// Extreme ratings can still crowd the right edge when the distribution has a +// wider tail range, especially on compact screens. +const stackedMarkerRatingThreshold = 3000 + +/** + * Formats percentile values for the rating comparison modal. + * + * Used by MemberRatingInfoModal to keep the top percentile text consistent with + * the compact rating card, including showing positive sub-1% values as 1%. + * + * @param {number | undefined} percentile - The percentile value calculated from the rating distribution. + * @returns {string} A display-ready percentage or `--` when the percentile is unavailable. + */ +const formatPercentile = (percentile?: number): string => ( + percentile === undefined || percentile === 0 + ? '--' + : formatTopPercentile(percentile) +) + +/** + * Returns the Topcoder rating tier metadata for a rating. + * + * Used by MemberRatingInfoModal to color the summary, histogram, and legend. + * + * @param {number | undefined} rating - The member rating value or a rating range start. + * @returns {RatingTier} The tier metadata that matches the rating. + */ +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 selectedRatingTier: 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 shouldStackMarkerRating: boolean = props.rating !== undefined && ( + markerPosition >= stackedMarkerPositionThreshold + || props.rating >= stackedMarkerRatingThreshold + ) + 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 === selectedRatingTier.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..d460314b7 --- /dev/null +++ b/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.spec.tsx @@ -0,0 +1,127 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import type { PropsWithChildren, ReactNode } from 'react' +import { fireEvent, 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 + +/** + * Builds a profile award fixture for CommunityAwards tests. The badge name and + * index are used to create stable display text and IDs, it returns a UserBadge + * accepted by the component, and it does not raise exceptions. + */ +function createBadge(badgeName: string, index: number = 1): UserBadge { + const badgeId: string = `badge-${index}` + + 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/${badgeId}.svg`, + badge_name: badgeName, + badge_status: 'active', + id: badgeId, + organization_id: 'topcoder', + orgranization: { + id: 'topcoder', + name: 'Topcoder', + }, + tags_id_tags: [], + }, + org_badge_id: badgeId, + 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 }) + }) + + it('lets members expand awards and collapse them back to the default view', () => { + const memberBadges: UserBadgesResponse = { + count: 6, + rows: Array.from({ length: 6 }, (_, index) => createBadge(`AI Award ${index + 1}`, index + 1)), + } + + mockUseMemberBadges.mockReturnValue(memberBadges) + + render() + + expect(screen.getByRole('button', { name: 'View AI Award 1 award details' })) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'View AI Award 4 award details' })) + .toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'View AI Award 5 award details' })) + .not + .toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '+ 2 more badges' })) + + expect(screen.getByRole('button', { name: 'View AI Award 5 award details' })) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'See less' })) + .toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'See less' })) + + expect(screen.queryByRole('button', { name: 'View AI Award 5 award details' })) + .not + .toBeInTheDocument() + expect(screen.getByRole('button', { name: '+ 2 more badges' })) + .toBeInTheDocument() + }) +}) 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..fafbab45a 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,77 @@ const CommunityAwards: FC = (props: CommunityAwardsProps) setSelectedBadge(badge) }, []) - return memberBadges && memberBadges.count ? ( + useEffect(() => { + setIsAwardsExpanded(false) + }, [props.profile?.userId]) + + /** + * Toggles the awards section between the collapsed four-badge preview and + * the expanded list. It is used by the more/less control, takes no + * parameters, returns nothing, and does not raise exceptions. + */ + function handleAwardsToggleClick(): void { + setIsAwardsExpanded(isExpanded => !isExpanded) + } + + 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} -
    + + )) }
    + {additionalBadgeCount > 0 && ( + + )} + { selectedBadge && ( ) diff --git a/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss b/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss index f6df932a7..85e4f51d0 100644 --- a/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss +++ b/src/apps/profiles/src/member-profile/page-layout/ProfilePageLayout.module.scss @@ -118,7 +118,7 @@ .profileInfoLeft { display: flex; flex-direction: column; - margin-top: -60px; + margin-top: -120px; @include ltelg { margin-top: 0; @@ -160,7 +160,7 @@ .expirenceWrap { display: grid; grid-template-columns: 1fr; - gap: 40px; + gap: 0; @include ltelg { grid-template-columns: 1fr; 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.module.scss b/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.module.scss index 1456b7593..b33247b7a 100644 --- a/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.module.scss +++ b/src/apps/profiles/src/member-profile/profile-header/ProfileHeader.module.scss @@ -187,10 +187,18 @@ gap: $sp-4; align-items: end; max-width: 500px; + + @include ltelg { + align-items: flex-start; + } } .openToWorkSummary { text-align: right; + + @include ltelg { + text-align: left; + } } .roleText { @@ -199,4 +207,4 @@ :global(#start-hiring-form) { min-height: 380px; -} \ No newline at end of file +} 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..c77af28d9 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,68 @@ flex-wrap: wrap; gap: $sp-2; } + +.principalSkillsToggle { + @include resetBtnStyle; + + align-self: center; + color: $link-blue-dark; + cursor: pointer; + font-family: $font-roboto; + font-size: 12px; + line-height: 16px; + padding: $sp-2 0; + text-align: left; + + &:hover { + color: darken($link-blue-dark, 5); + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid $link-blue-dark; + outline-offset: $sp-1; + border-radius: $sp-1; + } +} + +.additionalSkillsWrap { + display: flex; + flex-direction: column; +} + +.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.spec.tsx b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.spec.tsx new file mode 100644 index 000000000..c1582cfcf --- /dev/null +++ b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.spec.tsx @@ -0,0 +1,168 @@ +/* eslint-disable import/first, import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { fireEvent, render, screen } from '@testing-library/react' +import type { PropsWithChildren } from 'react' +import { MemoryRouter } from 'react-router-dom' + +type TestSkill = { + category: { + id: string + name: string + } + displayMode: { + id: string + name: string + } + id: string + levels: Array + name: string +} + +jest.mock('~/libs/core', () => ({ + getMemberSkillDetails: jest.fn(), + UserRole: { + administrator: 'administrator', + talentManager: 'talentManager', + }, + UserSkillDisplayModes: { + additional: 'additional', + principal: 'principal', + }, +}), { virtual: true }) + +jest.mock('~/libs/shared', () => { + const React = jest.requireActual('react') + + return { + GroupedSkillsUI: (): JSX.Element =>
    , + HowSkillsWorkModal: (): JSX.Element =>
    , + isSkillVerified: (): boolean => false, + SkillPill: (props: { skill: Pick }): JSX.Element => ( + {props.skill.name} + ), + useLocalStorage: (_key: string, initialValue: unknown): [unknown, (value: unknown) => void] => ( + React.useState(initialValue) + ), + } +}, { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + Button: (props: { label: string, onClick: () => void }): JSX.Element => ( + + ), + IconSolid: { + ChevronDownIcon: (): JSX.Element => , + ChevronUpIcon: (): JSX.Element => , + }, +}), { + virtual: true, +}) + +jest.mock('../../components', () => ({ + AddButton: (props: { label: string, onClick: () => void }): JSX.Element => ( + + ), + EditMemberPropertyBtn: (props: { onClick: () => void }): JSX.Element => ( + + ), + EmptySection: (props: PropsWithChildren<{ title: string }>): JSX.Element => ( +
    +

    {props.title}

    + {props.children} +
    + ), +})) + +jest.mock('../MemberProfile.context', () => ({ + useMemberProfileContext: (): { isTalentSearch: boolean } => ({ + isTalentSearch: false, + }), +})) + +jest.mock('./ModifySkillsModal', () => ({ + ModifySkillsModal: (): JSX.Element =>
    , +})) + +jest.mock('./PrincipalSkillsModal', () => ({ + PrincipalSkillsModal: (): JSX.Element =>
    , +})) + +import MemberSkillsInfo from './MemberSkillsInfo' + +/** + * Creates a principal UserSkill test fixture with stable IDs and display mode. + * + * @param name Display name for the skill. + * @param index Numeric suffix used to keep IDs deterministic. + * @returns A UserSkill fixture that can be rendered by MemberSkillsInfo. + * @throws This helper does not throw errors. + */ +function createPrincipalSkill(name: string, index: number): TestSkill { + return { + category: { + id: 'category-design', + name: 'Design', + }, + displayMode: { + id: 'display-principal', + name: 'principal', + }, + id: `skill-${index}`, + levels: [], + name, + } +} + +describe('MemberSkillsInfo', () => { + it('shows five principal skills before expanding the remaining skills', () => { + const profile = { + handle: 'tester', + skills: [ + 'AI', + 'BDD', + 'BIOS', + 'CAL', + 'Documentation', + 'NLP', + 'Security Testing', + ].map(createPrincipalSkill), + } + + render( + + + , + ) + + expect(screen.getByText('AI')) + .toBeInTheDocument() + expect(screen.getByText('Documentation')) + .toBeInTheDocument() + expect(screen.queryByText('NLP')) + .not + .toBeInTheDocument() + expect(screen.getByRole('button', { name: '+ 2 more skills' })) + .toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '+ 2 more skills' })) + + expect(screen.getByText('NLP')) + .toBeInTheDocument() + expect(screen.getByText('Security Testing')) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'See less' })) + .toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'See less' })) + + expect(screen.queryByText('NLP')) + .not + .toBeInTheDocument() + expect(screen.getByRole('button', { name: '+ 2 more skills' })) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx index 334f5f9b0..be54d8c4f 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' @@ -15,6 +15,8 @@ import { ModifySkillsModal } from './ModifySkillsModal' import { PrincipalSkillsModal } from './PrincipalSkillsModal' import styles from './MemberSkillsInfo.module.scss' +const COLLAPSED_PRINCIPAL_SKILL_COUNT = 5 + interface MemberSkillsInfoProps { profile: UserProfile authProfile: UserProfile | undefined @@ -76,6 +78,21 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp const [principalIntroModalVisible, setPrincipalIntroModalVisible]: [boolean, Dispatch>] = useState(false) + const [ + isAdditionalSkillsExpanded, + setIsAdditionalSkillsExpanded, + ]: [boolean, Dispatch>] = useState(false) + + const [ + isPrincipalSkillsExpanded, + setIsPrincipalSkillsExpanded, + ]: [boolean, Dispatch>] = useState(false) + + useEffect(() => { + setIsAdditionalSkillsExpanded(false) + setIsPrincipalSkillsExpanded(false) + }, [props.profile.handle]) + useEffect(() => { if (props.authProfile && editMode === profileEditModes.skills) { setIsEditMode(true) @@ -133,6 +150,29 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp setPrincipalIntroModalVisible(false) } + /** + * Toggles Principal Skills between the five-skill preview and full list. + * + * Used by the Principal Skills more/less control on the member profile + * page. It takes no parameters, returns no value, and does not throw. + */ + function handlePrincipalSkillsToggle(): void { + setIsPrincipalSkillsExpanded(isExpanded => !isExpanded) + } + + /** + * Toggles visibility for the Additional Skills section from the section arrow. + * + * 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) { @@ -142,6 +182,14 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp throw e }), [props.profile.handle]) + const visiblePrincipalSkills: UserSkill[] = isPrincipalSkillsExpanded + ? principalSkills + : principalSkills.slice(0, COLLAPSED_PRINCIPAL_SKILL_COUNT) + const hiddenPrincipalSkillCount: number = Math.max( + principalSkills.length - COLLAPSED_PRINCIPAL_SKILL_COUNT, + 0, + ) + return (
    @@ -180,7 +228,7 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp Principal Skills
    - {principalSkills.map((skill: UserSkill) => ( + {visiblePrincipalSkills.map((skill: UserSkill) => ( = (props: MemberSkillsInfoProp fetchSkillDetails={canFetchSkillDetails ? fetchSkillDetails : undefined} /> ))} + {hiddenPrincipalSkillCount > 0 && ( + + )}
    )} {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/apps/profiles/src/member-profile/tc-achievements/sub-track-view/SubTrackView.tsx b/src/apps/profiles/src/member-profile/tc-achievements/sub-track-view/SubTrackView.tsx index afda7afcc..36265aa34 100644 --- a/src/apps/profiles/src/member-profile/tc-achievements/sub-track-view/SubTrackView.tsx +++ b/src/apps/profiles/src/member-profile/tc-achievements/sub-track-view/SubTrackView.tsx @@ -4,7 +4,7 @@ import { isEmpty } from 'lodash' import { MemberStats, UserProfile } from '~/libs/core' -import { useFetchSubTrackData, useTrackHistory } from '../../../hooks' +import { getSubTrackSummaryStats, useFetchSubTrackData, useTrackHistory } from '../../../hooks' import { StatsDetailsLayout } from '../../../components/tc-achievements/StatsDetailsLayout' import { DevelopTrackView } from '../../../components/tc-achievements/DevelopTrackView' import { SRMView } from '../../../components/tc-achievements/SRMView' @@ -25,6 +25,18 @@ const SubTrackView: FC = props => { const subTrackResult = useFetchSubTrackData(props.profile.handle, params.trackType, params.subTrack) const { trackData, ...subTrackData }: any = subTrackResult ?? {} const trackHistory = useTrackHistory(props.profile.handle, subTrackData as MemberStats | undefined) + const summaryTrackData: MemberStats = useMemo(() => { + const summaryStats = getSubTrackSummaryStats(subTrackData as MemberStats, trackHistory) + + return { + ...subTrackData, + submissions: { + ...(subTrackData.submissions ?? {}), + submissions: summaryStats.submissions, + }, + wins: summaryStats.wins, + } as MemberStats + }, [subTrackData, trackHistory]) const [backRoute, prevTitle] = useMemo(() => { const trackName = trackData?.subTracks?.length === 1 ? '' : trackData?.name ?? '' @@ -41,7 +53,7 @@ const SubTrackView: FC = props => { title={subTrackLabelToHumanName(subTrackData.name)} backAction={backRoute} closeAction={statsRoute(props.profile.handle)} - trackData={subTrackData} + trackData={summaryTrackData} > {subTrackData.name === 'SRM' ? ( diff --git a/src/apps/profiles/src/member-profile/tc-achievements/track-view/TrackView.tsx b/src/apps/profiles/src/member-profile/tc-achievements/track-view/TrackView.tsx index 69ba5b061..d0819bffd 100644 --- a/src/apps/profiles/src/member-profile/tc-achievements/track-view/TrackView.tsx +++ b/src/apps/profiles/src/member-profile/tc-achievements/track-view/TrackView.tsx @@ -1,10 +1,15 @@ import { FC, ReactElement, useMemo } from 'react' import { Link, useParams } from 'react-router-dom' -import { orderBy } from 'lodash' +import { orderBy, sumBy } from 'lodash' -import { MemberStats, UserProfile } from '~/libs/core' +import { MemberStats, UserProfile, UserStatsHistory, useStatsHistory } from '~/libs/core' -import { getSubTrackSubmissionCount, useFetchTrackData } from '../../../hooks' +import { + getSubTrackSummaryStats, + getTrackHistoryFromStats, + SubTrackSummaryStats, + useFetchTrackData, +} from '../../../hooks' import { StatsDetailsLayout } from '../../../components/tc-achievements/StatsDetailsLayout' import { SubTrackSummaryCard } from '../../../components/tc-achievements/SubTrackSummaryCard' import { MemberProfileContextValue, useMemberProfileContext } from '../../MemberProfile.context' @@ -16,38 +21,60 @@ interface TrackViewProps { renderDefault: () => ReactElement } +type SubTrackDisplayStats = [MemberStats, SubTrackSummaryStats] + const TrackView: FC = props => { const { statsRoute }: MemberProfileContextValue = useMemberProfileContext() const params = useParams() const trackData = useFetchTrackData(props.profile.handle, params.trackType) + const statsHistory: UserStatsHistory | undefined = useStatsHistory(props.profile.handle) + + const subTrackStats = useMemo(() => ( + trackData?.subTracks.map(subTrack => { + const trackHistory = getTrackHistoryFromStats(statsHistory, subTrack) - const subTracks: MemberStats[] = useMemo(() => orderBy( - trackData?.subTracks, - ['wins', 'submissions.submissions', 'challenges'], + return [ + subTrack, + getSubTrackSummaryStats(subTrack, trackHistory), + ] as SubTrackDisplayStats + }) ?? [] + ), [statsHistory, trackData?.subTracks]) + const subTracks = useMemo(() => orderBy( + subTrackStats, + [ + subTrack => subTrack[1].wins, + subTrack => subTrack[1].submissions, + subTrack => subTrack[0].challenges, + ], ['desc', 'desc', 'desc'], - ), [trackData?.subTracks]) + ), [subTrackStats]) + const displayTrackData = useMemo(() => (trackData ? { + ...trackData, + submissions: sumBy(subTrackStats, subTrack => subTrack[1].submissions), + wins: sumBy(subTrackStats, subTrack => subTrack[1].wins), + } : undefined), [subTrackStats, trackData]) - return !trackData ? props.renderDefault() : ( + return !displayTrackData ? props.renderDefault() : (
    - {subTracks.map((subTrack: MemberStats) => ( + {subTracks.map(([subTrack, stats]: SubTrackDisplayStats) => ( ))} diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx index 384489ada..a78b49b35 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -140,15 +140,12 @@ function resolveHandle( * Pass false for reviewer role so author identity is hidden. * Defaults to true. * @param resourceMemberIdMapping - Map of memberId → BackendResource used to resolve handles. - * @param submissionLocked - When true, labels the reason as "Locked Reason"; - * otherwise labels it as "Unlock Reason". */ function buildDecisionNotes( escalations?: AiReviewDecisionEscalation[], reason?: string | null, showAuthor: boolean = true, resourceMemberIdMapping: Record = {}, - submissionLocked: boolean = false, ): string[] { const parts: string[] = [] @@ -172,8 +169,7 @@ function buildDecisionNotes( }) if (reason) { - const reasonLabel: string = submissionLocked ? 'Locked Reason' : 'Unlock Reason' - parts.push(`${reasonLabel}: ${reason}`) + parts.push(`Locked Reason: ${reason}`) } return parts @@ -392,7 +388,6 @@ const AiReviewsTable: FC = props => { currentDecision.reason, canSeeAuthor, resourceMemberIdMapping, - currentDecision.submissionLocked, ) }, [canSeeAuthor, currentDecision, hasSubmitterRole, resourceMemberIdMapping]) diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx index 8cbd9e45e..8f54de7ef 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx @@ -377,6 +377,35 @@ describe('BillingAccountLineItemsModal', () => { ) }) + it('uses persisted engagement payment split amounts before deriving markup', () => { + renderModal({ + ...baseBillingAccountDetails, + consumedAmounts: [ + { + amount: '762.66', + challengeFee: '420.66', + date: '2026-06-02T13:10:48.235Z', + externalId: 'assignment-5245', + externalName: 'Eng BA', + externalType: 'ENGAGEMENT', + paymentAmount: '342.00', + }, + ], + consumedBudget: 762.66, + markup: 0.01226408, + totalBudgetRemaining: 237.34, + }) + + expect(screen.getByText('$342.00')) + .toBeTruthy() + expect(screen.getByText('$420.66')) + .toBeTruthy() + expect(screen.queryByText('$753.42')) + .toBeNull() + expect(screen.queryByText('$9.24')) + .toBeNull() + }) + it('builds engagement links from assignment-backed billing rows for copilot views', () => { mockedUseFetchEngagements.mockReturnValue({ engagements: [ diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx index 3aecd2cde..f35b291d2 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -371,10 +371,11 @@ function getConsumedChallengeFeeAmount( * @param displayAmount Member-payment amount selected for display. * @param billingAccountDetails Billing account detail payload containing hidden markup when available. * @param challengeDetailsById Hydrated challenge details, or `undefined` while loading. - * @returns Persisted consumed challenge fee, calculated markup fee, or + * @returns Persisted engagement or consumed challenge fee, calculated markup fee, or * `undefined` when the fee cannot be derived. - * @remarks Consumed challenge rows with an explicit member subtotal do not - * need challenge markup hydration to show the correct fee. + * @remarks Engagement payment rows can expose the exact finance split. + * Consumed challenge rows with an explicit member subtotal do not need + * challenge markup hydration to show the correct fee. */ function getLineItemChallengeFeeAmount( item: BillingAccountLineItem, @@ -382,6 +383,10 @@ function getLineItemChallengeFeeAmount( billingAccountDetails: BillingAccountDetails, challengeDetailsById: ChallengeDetailsById | undefined, ): number | undefined { + if (item.externalType === 'ENGAGEMENT' && item.challengeFee !== undefined) { + return Number(item.challengeFee.toFixed(2)) + } + const consumedChallengeFeeAmount = getConsumedChallengeFeeAmount(item) if (consumedChallengeFeeAmount !== undefined) { @@ -401,13 +406,18 @@ function getLineItemChallengeFeeAmount( * @param item Raw locked or consumed billing-account engagement line item. * @param billingAccountDetails Billing account detail payload containing markup when available. * @returns Member payment amount when it can be derived. - * @remarks Engagement rows prefer API-provided member-payment amounts. When - * only the billing-account charge is available, markup is removed once. + * @remarks Engagement rows prefer persisted finance payment amounts, then + * API-provided member-payment aliases. When only the billing-account charge + * is available, markup is removed once. */ function getEngagementMemberPaymentAmount( item: BillingAccountLineItem, billingAccountDetails: BillingAccountDetails, ): number | undefined { + if (item.paymentAmount !== undefined) { + return item.paymentAmount + } + if (item.memberPaymentAmount !== undefined) { return item.memberPaymentAmount } diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx index d5122303b..1fd03c1d0 100644 --- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx +++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.spec.tsx @@ -22,6 +22,13 @@ jest.mock('../../constants', () => ({ jest.mock('../../utils', () => ({ formatDateTime: (value: string) => value, getRatingLevel: () => 'gray', + getSubmissionExampleScore: ( + submission: { reviewSummation?: Array<{ aggregateScore?: number, isExample?: boolean }> }, + ) => ( + submission.reviewSummation + ?.find(item => item.isExample === true) + ?.aggregateScore + ), getSubmissionFinalScore: (submission: { review?: Array<{ finalScore?: number }> }) => ( submission.review?.[0]?.finalScore ?? 0 ), @@ -46,9 +53,10 @@ jest.mock('../../utils', () => ({ submission: { reviewSummation?: Array<{ metadata?: { - testProcess?: 'provisional' | 'system' + testProcess?: 'example' | 'provisional' | 'system' testProgress?: number testStatus?: 'FAILED' | 'IN PROGRESS' | 'SUCCESS' + testType?: 'example' | 'provisional' | 'system' } }> }, @@ -57,7 +65,7 @@ jest.mock('../../utils', () => ({ const progress = metadata?.testProgress return { - process: metadata?.testProcess, + process: metadata?.testProcess ?? metadata?.testType, progressPercent: typeof progress === 'number' ? `${Math.round(progress * 100)}%` : undefined, @@ -390,4 +398,90 @@ describe('SubmissionsTable', () => { expect(screen.queryByRole('link', { name: '80.00 / 85.00' })) .toBeNull() }) + + it('renders example validation process and score for marathon submissions', () => { + render( + , + ) + + expect(screen.getByRole('link', { name: '15.25 / -' })) + .toBeTruthy() + expect(screen.getByText('Example')) + .toBeTruthy() + expect(screen.getByText('100%')) + .toBeTruthy() + expect(screen.getByRole('img', { name: 'Test status: SUCCESS' })) + .toBeTruthy() + }) + + it('renders failed example validation scores for marathon submissions', () => { + render( + , + ) + + expect(screen.getByRole('link', { name: '-1.00 / -' })) + .toBeTruthy() + expect(screen.getByText('Example')) + .toBeTruthy() + expect(screen.getByText('100%')) + .toBeTruthy() + expect(screen.getByRole('img', { name: 'Test status: FAILED' })) + .toBeTruthy() + }) }) diff --git a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx index bda7e5928..7b92996c7 100644 --- a/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx +++ b/src/apps/work/src/lib/components/SubmissionsTable/SubmissionsTable.tsx @@ -19,6 +19,7 @@ import { Submission } from '../../models' import { formatDateTime, getRatingLevel, + getSubmissionExampleScore, getSubmissionFinalScore, getSubmissionInitialScore, getSubmissionProvisionalScore, @@ -141,6 +142,50 @@ function formatScore(value?: number, emptyValue: string = 'N/A'): string { return value.toFixed(2) } +/** + * Converts normalized marathon test process metadata into a table display label. + * @param process Current marathon test process from review summation metadata. + * @returns Human-readable process label, or an empty string when absent. + * Used by `SubmissionsTable` for the Current tests process column. + */ +function formatTestProcess(process?: string): string { + if (process === 'example') { + return 'Example' + } + + if (process === 'provisional') { + return 'Provisional' + } + + if (process === 'system') { + return 'System' + } + + return '' +} + +/** + * Selects the marathon score shown in the left side of the score column. + * @param submission Submission row with review summation data. + * @param process Current marathon test process from progress metadata. + * @returns Example score when Example is current; otherwise the provisional score, including MM failure scores. + * Used by `SubmissionsTable` for marathon score display. + */ +function getMarathonInitialScore( + submission: Submission, + process?: string, +): number | undefined { + if (process === 'example') { + const exampleScore = getSubmissionExampleScore(submission) + + if (exampleScore !== undefined) { + return exampleScore + } + } + + return getSubmissionProvisionalScore(submission, true) +} + function getSortIndicator( fieldName: SubmissionSortBy | undefined, currentSortBy: SubmissionSortBy, @@ -327,20 +372,20 @@ export const SubmissionsTable: FC = ( const handleDisplay = getHandleDisplay(submission, !!props.isLoadingMembers) const emailDisplay = getEmailDisplay(submission, !!props.isLoadingMembers) const submissionDate = formatDateTime(getCreatedAt(submission)) + const testProgress = props.showMarathonMatchTestProgress + ? getSubmissionTestProgress(submission) + : undefined const initialScoreValue = props.showMarathonMatchTestProgress - ? getSubmissionProvisionalScore(submission) + ? getMarathonInitialScore(submission, testProgress?.process) : getSubmissionInitialScore(submission) const finalScoreValue = props.showMarathonMatchTestProgress - ? getSubmissionSystemScore(submission) + ? getSubmissionSystemScore(submission, true) : getSubmissionFinalScore(submission) const emptyScoreValue = props.showMarathonMatchTestProgress ? '-' : 'N/A' const initialScore = formatScore(initialScoreValue, emptyScoreValue) const finalScore = formatScore(finalScoreValue, emptyScoreValue) - const testProgress = props.showMarathonMatchTestProgress - ? getSubmissionTestProgress(submission) - : undefined const reviewTab = submission.type === 'CHECKPOINT_SUBMISSION' ? 'checkpoint-submission' : 'submission' @@ -396,7 +441,7 @@ export const SubmissionsTable: FC = ( ? ( <> - {testProgress?.process || ''} + {formatTestProcess(testProgress?.process)} diff --git a/src/apps/work/src/lib/components/form/FormMarkdownEditor/FormMarkdownEditor.module.scss b/src/apps/work/src/lib/components/form/FormMarkdownEditor/FormMarkdownEditor.module.scss index 986652af1..d54ae9fc9 100644 --- a/src/apps/work/src/lib/components/form/FormMarkdownEditor/FormMarkdownEditor.module.scss +++ b/src/apps/work/src/lib/components/form/FormMarkdownEditor/FormMarkdownEditor.module.scss @@ -1,15 +1,21 @@ .editor { - height: 420px; min-height: 280px; - overflow: hidden; - resize: vertical; +} + +.editor .markdownEditor { + height: auto; + min-height: 280px; :global(.EasyMDEContainer) { + height: auto; min-height: 280px; } -} -.editor .markdownEditor { - height: 100%; - min-height: 280px; + :global(.EasyMDEContainer .CodeMirror.CodeMirror-wrap) { + flex: none; + height: 340px; + min-height: 180px; + overflow: hidden; + resize: vertical; + } } diff --git a/src/apps/work/src/lib/constants/challenge-editor.constants.ts b/src/apps/work/src/lib/constants/challenge-editor.constants.ts index bf137422f..fbf7a6f80 100644 --- a/src/apps/work/src/lib/constants/challenge-editor.constants.ts +++ b/src/apps/work/src/lib/constants/challenge-editor.constants.ts @@ -1,8 +1,80 @@ +import { EnvironmentConfig } from '../../../../../config' + export const SPECIAL_CHALLENGE_TAGS: string[] = [ 'Marathon Match', 'Rapid Development Match', ] +export type CreateChallengeTypesByTrack = Record + +const DEFAULT_CREATE_CHALLENGE_TYPES_BY_TRACK: CreateChallengeTypesByTrack = { + DATA_SCIENCE: [ + 'Challenge', + 'First2Finish', + 'Marathon Match', + 'Task', + ], + DESIGN: [ + 'Challenge', + 'First2Finish', + 'Task', + ], + DEVELOP: [ + 'Challenge', + 'First2Finish', + 'Marathon Match', + 'Task', + ], + QA: [ + 'Challenge', + 'First2Finish', + 'Task', + ], +} + +/** + * Normalizes the configured create-challenge type allowlist for the work app. + * + * @param configValue environment config value from `WORK_CREATE_CHALLENGE_TYPES_BY_TRACK`. + * @returns configured track-to-type names when valid; otherwise the default work-app allowlist. + * @remarks Used by the create challenge type dropdown to keep hidden rating-specific types out of + * the launch flow while allowing deployment config to override the allowlist. + * @throws Does not throw. + */ +function parseCreateChallengeTypesByTrack( + configValue: unknown, +): CreateChallengeTypesByTrack { + if (!configValue || Array.isArray(configValue) || typeof configValue !== 'object') { + return DEFAULT_CREATE_CHALLENGE_TYPES_BY_TRACK + } + + const config = Object.entries(configValue) + .reduce((result, [track, challengeTypes]) => { + if (!Array.isArray(challengeTypes)) { + return result + } + + const normalizedChallengeTypes = challengeTypes + .filter((challengeType): challengeType is string => ( + typeof challengeType === 'string' && challengeType.trim().length > 0 + )) + .map(challengeType => challengeType.trim()) + + if (track.trim().length > 0 && normalizedChallengeTypes.length > 0) { + result[track.trim()] = normalizedChallengeTypes + } + + return result + }, {}) + + return Object.keys(config).length > 0 ? config : DEFAULT_CREATE_CHALLENGE_TYPES_BY_TRACK +} + +export const CREATE_CHALLENGE_TYPES_BY_TRACK = parseCreateChallengeTypesByTrack( + (EnvironmentConfig as unknown as Record) + .WORK_CREATE_CHALLENGE_TYPES_BY_TRACK, +) + export const SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS: string[] = ['80000062'] export const MAX_CHALLENGE_NAME_LENGTH = 200 @@ -58,6 +130,10 @@ export const CHALLENGE_TYPES_WITH_MULTIPLE_PRIZES = [ export const DEFAULT_NDA_UUID = 'e5811a7b-43d1-407a-a064-69e5015b4900' +export const DEFAULT_STANDARD_TERMS_UUID = process.env.REACT_APP_DEFAULT_STANDARD_TERMS_UUID + || process.env.DEFAULT_STANDARD_TERMS_UUID + || EnvironmentConfig.DEFAULT_STANDARD_TERMS_UUID + export const ROUND_TYPES = { SINGLE_ROUND: 'Single round', TWO_ROUNDS: 'Two rounds', diff --git a/src/apps/work/src/lib/models/MarathonMatch.model.ts b/src/apps/work/src/lib/models/MarathonMatch.model.ts index 40f21ef04..1d72bfeab 100644 --- a/src/apps/work/src/lib/models/MarathonMatch.model.ts +++ b/src/apps/work/src/lib/models/MarathonMatch.model.ts @@ -166,15 +166,38 @@ export interface MarathonMatchTestSubmissionInput { /** * Response returned after uploading one Marathon Match validation submission. - * Used by the scorer section to show the created submission and queued ECS task. + * Used by the scorer section to start polling the isolated validation run. */ export interface MarathonMatchTestSubmissionResponse { challengeId: string cloudWatchLogsConsoleUrl?: string configType: MarathonMatchConfigType + status: string submissionId: string taskArn: string taskId: string + testSubmissionId: string +} + +/** + * Current status and final scorer details for an isolated validation submission run. + * Used by the scorer section result modal after polling completes. + */ +export interface MarathonMatchTestSubmissionStatusResponse extends MarathonMatchTestSubmissionResponse { + completedAt?: string + completedTests?: number + currentReview?: Record + failedTests?: number + fileName: string + fileSize: number + impactedReviews?: Record[] + memberId?: string + message?: string + metadata?: Record + progress?: number + score?: number + totalTests?: number + updatedAt: string } /** diff --git a/src/apps/work/src/lib/services/billing-accounts.service.spec.ts b/src/apps/work/src/lib/services/billing-accounts.service.spec.ts index dbf00f65d..93a957f8d 100644 --- a/src/apps/work/src/lib/services/billing-accounts.service.spec.ts +++ b/src/apps/work/src/lib/services/billing-accounts.service.spec.ts @@ -177,12 +177,14 @@ describe('combineBillingAccountLineItems', () => { budget: 2000, consumedAmounts: [ { - amount: '75', + amount: '762.66', + challengeFee: '420.66', date: '2026-02-12T00:00:00.000Z', engagementId: 'engagement-300', externalId: 'assignment-300', externalName: 'Engagement Assignment', externalType: 'ENGAGEMENT', + paymentAmount: '342.00', }, { amount: '75', @@ -192,7 +194,7 @@ describe('combineBillingAccountLineItems', () => { externalType: 'ENGAGEMENT', }, ], - consumedBudget: 150, + consumedBudget: 837.66, id: 80001063, lockedAmounts: [ { @@ -249,10 +251,12 @@ describe('combineBillingAccountLineItems', () => { .toHaveLength(2) expect(consumedRows[0]) .toMatchObject({ + challengeFee: 420.66, date: '2026-02-12T00:00:00.000Z', engagementId: 'engagement-300', externalId: 'assignment-300', externalType: 'ENGAGEMENT', + paymentAmount: 342, status: 'consumed', }) expect(consumedRows[1]) diff --git a/src/apps/work/src/lib/services/billing-accounts.service.ts b/src/apps/work/src/lib/services/billing-accounts.service.ts index 0d0923e90..880c5fdf7 100644 --- a/src/apps/work/src/lib/services/billing-accounts.service.ts +++ b/src/apps/work/src/lib/services/billing-accounts.service.ts @@ -25,12 +25,14 @@ export type BillingAccountExternalType = 'CHALLENGE' | 'ENGAGEMENT' export interface BillingAccountBudgetEntry { amount: number | string challengeId?: string + challengeFee?: number | string date: string engagementId?: string externalId?: string externalName: string | null externalType: BillingAccountExternalType memberPaymentAmount?: number | string + paymentAmount?: number | string } export type BillingAccountLockedAmount = BillingAccountBudgetEntry @@ -40,12 +42,14 @@ export interface BillingAccountLineItem { id: string amount: number challengeId?: string + challengeFee?: number date: string engagementId?: string externalId?: string externalName?: string | null externalType: BillingAccountExternalType memberPaymentAmount?: number + paymentAmount?: number status: BillingAccountLineItemStatus } @@ -170,8 +174,9 @@ function createLineItemKey( * @param index The entry index within its source bucket, used in the generated row key. * @returns A normalized line item with numeric amount, original date, display * fallback for nullable external names, optional canonical external id, - * optional engagement id, optional legacy challenge id, optional copilot-safe - * member payment amount, and a deterministic UI row key. + * optional engagement id, optional legacy challenge id, optional persisted + * payment split amounts, optional copilot-safe member payment amount, and a + * deterministic UI row key. */ function createLineItem( status: BillingAccountLineItemStatus, @@ -211,6 +216,18 @@ function createLineItem( lineItem.memberPaymentAmount = memberPaymentAmount } + const paymentAmount = Number(item.paymentAmount) + + if (Number.isFinite(paymentAmount)) { + lineItem.paymentAmount = paymentAmount + } + + const challengeFee = Number(item.challengeFee) + + if (Number.isFinite(challengeFee)) { + lineItem.challengeFee = challengeFee + } + return lineItem } @@ -257,10 +274,11 @@ export async function searchBillingAccounts( * Fetches a single billing account by its identifier. * * The detail payload includes budget totals plus locked and consumed external - * entries with `amount`, optional copilot-safe `memberPaymentAmount`, `date`, + * entries with `amount`, optional persisted `paymentAmount` and + * `challengeFee`, optional copilot-safe `memberPaymentAmount`, `date`, * optional canonical `externalId`, optional `engagementId`, `externalType`, - * and nullable `externalName`. Top-level `id` and `name` remain available - * for lookup labels. + * and nullable `externalName`. Top-level `id` and `name` remain available for + * lookup labels. */ export async function fetchBillingAccountById( billingAccountId: string, @@ -285,8 +303,8 @@ export async function fetchBillingAccountById( * * @param details Billing account details containing locked and consumed entry arrays. * @returns Normalized line items with numeric amounts, optional engagement - * ids, optional member payment amounts, API dates, external metadata, - * status, and UI row keys. + * ids, optional persisted payment split amounts, optional member payment + * amounts, API dates, external metadata, status, and UI row keys. */ export function combineBillingAccountLineItems( details: BillingAccountDetails, diff --git a/src/apps/work/src/lib/services/marathon-match.service.spec.ts b/src/apps/work/src/lib/services/marathon-match.service.spec.ts index 0b228fe84..ffe36d36e 100644 --- a/src/apps/work/src/lib/services/marathon-match.service.spec.ts +++ b/src/apps/work/src/lib/services/marathon-match.service.spec.ts @@ -1,7 +1,13 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ -import { xhrPostAsync } from '~/libs/core' +import { + xhrGetAsync, + xhrPostAsync, +} from '~/libs/core' -import { uploadMarathonMatchTestSubmission } from './marathon-match.service' +import { + fetchMarathonMatchTestSubmissionStatus, + uploadMarathonMatchTestSubmission, +} from './marathon-match.service' jest.mock('~/libs/core', () => ({ xhrGetAsync: jest.fn(), @@ -30,9 +36,11 @@ describe('marathon-match.service', () => { challengeId: '30000123', cloudWatchLogsConsoleUrl: 'https://logs.example.com/task-1', configType: 'PROVISIONAL', - submissionId: 'submission-1', + status: 'QUEUED', + submissionId: 'validation-run-1', taskArn: 'arn:aws:ecs:task/task-1', taskId: 'task-1', + testSubmissionId: 'validation-run-1', }) await expect( @@ -46,9 +54,11 @@ describe('marathon-match.service', () => { challengeId: '30000123', cloudWatchLogsConsoleUrl: 'https://logs.example.com/task-1', configType: 'PROVISIONAL', - submissionId: 'submission-1', + status: 'QUEUED', + submissionId: 'validation-run-1', taskArn: 'arn:aws:ecs:task/task-1', taskId: 'task-1', + testSubmissionId: 'validation-run-1', }) expect(mockedPostAsync) @@ -92,4 +102,54 @@ describe('marathon-match.service', () => { .rejects .toThrow('Marathon match validation upload response was invalid') }) + + it('fetches validation submission status details', async () => { + const mockedGetAsync = xhrGetAsync as jest.Mock + + mockedGetAsync.mockResolvedValue({ + challengeId: '30000123', + cloudWatchLogsConsoleUrl: 'https://logs.example.com/task-1', + completedAt: '2026-06-17T01:02:03.000Z', + completedTests: 50, + configType: 'PROVISIONAL', + failedTests: 0, + fileName: 'solution.zip', + fileSize: 3, + metadata: { + testType: 'provisional', + }, + progress: 1, + score: 88.5, + status: 'SUCCESS', + submissionId: 'validation-run-1', + taskArn: 'arn:aws:ecs:task/task-1', + taskId: 'task-1', + testSubmissionId: 'validation-run-1', + totalTests: 50, + updatedAt: '2026-06-17T01:02:03.000Z', + }) + + await expect( + fetchMarathonMatchTestSubmissionStatus( + '30000123', + 'validation-run-1', + ), + ) + .resolves + .toMatchObject({ + challengeId: '30000123', + fileName: 'solution.zip', + metadata: { + testType: 'provisional', + }, + score: 88.5, + status: 'SUCCESS', + testSubmissionId: 'validation-run-1', + }) + + expect(mockedGetAsync) + .toHaveBeenCalledWith( + 'https://example.com/marathon-match/challenge/30000123/test-submission/validation-run-1', + ) + }) }) diff --git a/src/apps/work/src/lib/services/marathon-match.service.ts b/src/apps/work/src/lib/services/marathon-match.service.ts index 8c8f4871f..855a1f649 100644 --- a/src/apps/work/src/lib/services/marathon-match.service.ts +++ b/src/apps/work/src/lib/services/marathon-match.service.ts @@ -24,6 +24,7 @@ import { MarathonMatchTesterSummary, MarathonMatchTestSubmissionInput, MarathonMatchTestSubmissionResponse, + MarathonMatchTestSubmissionStatusResponse, UpdateMarathonMatchConfigInput, } from '../models' @@ -44,6 +45,36 @@ export interface FetchTestersParams { type TestSubmissionResponseConfigType = MarathonMatchTestSubmissionResponse['configType'] +/** + * Normalizes a JSON-like value into a plain object record. + * @param value Candidate API payload fragment. + * @returns Record value when the fragment is an object; otherwise `undefined`. + * Used by validation-submission status normalization for metadata fields. + */ +function normalizeRecord(value: unknown): Record | undefined { + if (typeof value !== 'object' || !value || Array.isArray(value)) { + return undefined + } + + return value as Record +} + +/** + * Normalizes a JSON-like value into an array of plain object records. + * @param value Candidate API payload fragment. + * @returns Record array when the fragment is an array of objects; otherwise `undefined`. + * Used by validation-submission status normalization for impacted review details. + */ +function normalizeRecordArray(value: unknown): Record[] | undefined { + if (!Array.isArray(value)) { + return undefined + } + + return value + .map(normalizeRecord) + .filter((entry): entry is Record => !!entry) +} + function normalizeText(value: unknown): string | undefined { if (value === undefined || value === null) { return undefined @@ -488,10 +519,12 @@ function normalizeMarathonMatchTestSubmissionResponse( const challengeId = normalizeText(typedResponse.challengeId) const configType = normalizeText(typedResponse.configType) as TestSubmissionResponseConfigType | undefined const submissionId = normalizeText(typedResponse.submissionId) + const testSubmissionId = normalizeText(typedResponse.testSubmissionId) || submissionId + const status = normalizeText(typedResponse.status) const taskArn = normalizeText(typedResponse.taskArn) const taskId = normalizeText(typedResponse.taskId) - if (!challengeId || !configType || !submissionId || !taskArn || !taskId) { + if (!challengeId || !configType || !submissionId || !testSubmissionId || !status || !taskArn || !taskId) { return undefined } @@ -499,9 +532,54 @@ function normalizeMarathonMatchTestSubmissionResponse( challengeId, cloudWatchLogsConsoleUrl: normalizeText(typedResponse.cloudWatchLogsConsoleUrl), configType, + status, submissionId, taskArn, taskId, + testSubmissionId, + } +} + +/** + * Normalizes a raw validation submission status response. + * @param response Raw response from GET /challenge/:challengeId/test-submission/:testSubmissionId. + * @returns Normalized status response when required fields are present; otherwise `undefined`. + * Used by `fetchMarathonMatchTestSubmissionStatus` before resolving the API call. + */ +function normalizeMarathonMatchTestSubmissionStatusResponse( + response: unknown, +): MarathonMatchTestSubmissionStatusResponse | undefined { + const baseResponse = normalizeMarathonMatchTestSubmissionResponse(response) + + if (!baseResponse || typeof response !== 'object' || !response) { + return undefined + } + + const typedResponse = response as Record + const fileName = normalizeText(typedResponse.fileName) + const fileSize = normalizeNumber(typedResponse.fileSize) + const updatedAt = normalizeText(typedResponse.updatedAt) + + if (!fileName || fileSize === undefined || !updatedAt) { + return undefined + } + + return { + ...baseResponse, + completedAt: normalizeText(typedResponse.completedAt), + completedTests: normalizeNumber(typedResponse.completedTests), + currentReview: normalizeRecord(typedResponse.currentReview), + failedTests: normalizeNumber(typedResponse.failedTests), + fileName, + fileSize, + impactedReviews: normalizeRecordArray(typedResponse.impactedReviews), + memberId: normalizeText(typedResponse.memberId), + message: normalizeText(typedResponse.message), + metadata: normalizeRecord(typedResponse.metadata), + progress: normalizeNumber(typedResponse.progress), + score: normalizeNumber(typedResponse.score), + totalTests: normalizeNumber(typedResponse.totalTests), + updatedAt, } } @@ -741,6 +819,36 @@ export async function uploadMarathonMatchTestSubmission( } } +/** + * Fetches the current status for an isolated marathon match validation submission run. + * @param challengeId Challenge identifier used in the validation status route path. + * @param testSubmissionId Validation run identifier returned by upload. + * @returns Current validation run progress and final scoring details when complete. + * @throws Error When the API request fails or returns an invalid status response. + * Used by `MarathonMatchScorerSection` to poll until validation scoring completes. + */ +export async function fetchMarathonMatchTestSubmissionStatus( + challengeId: string, + testSubmissionId: string, +): Promise { + try { + const encodedChallengeId = encodeURIComponent(challengeId.trim()) + const encodedTestSubmissionId = encodeURIComponent(testSubmissionId.trim()) + const response = await xhrGetAsync( + `${MARATHON_MATCH_API_URL}/challenge/${encodedChallengeId}/test-submission/${encodedTestSubmissionId}`, + ) + const normalizedResponse = normalizeMarathonMatchTestSubmissionStatusResponse(response) + + if (!normalizedResponse) { + throw new Error('Marathon match validation submission status response was invalid') + } + + return normalizedResponse + } catch (error) { + throw normalizeError(error, 'Failed to fetch marathon match validation submission status') + } +} + /** * Lists available testers for scorer configuration. * @param params Optional tester-name filter and pagination controls. diff --git a/src/apps/work/src/lib/utils/challenge.utils.spec.ts b/src/apps/work/src/lib/utils/challenge.utils.spec.ts index 142b50072..535bf3a99 100644 --- a/src/apps/work/src/lib/utils/challenge.utils.spec.ts +++ b/src/apps/work/src/lib/utils/challenge.utils.spec.ts @@ -1,6 +1,8 @@ import { + getSubmissionExampleScore, getSubmissionProvisionalScore, getSubmissionSystemScore, + getSubmissionTestProgress, isMarathonMatchChallenge, } from './challenge.utils' @@ -34,6 +36,87 @@ describe('challenge utils', () => { }) }) + describe('getSubmissionExampleScore', () => { + it('returns only example marathon scores', () => { + expect(getSubmissionExampleScore({ + reviewSummation: [ + { + aggregateScore: 10, + isExample: true, + metadata: { + testType: 'example', + }, + }, + { + aggregateScore: 12, + isProvisional: true, + metadata: { + testProcess: 'provisional', + }, + }, + ], + })) + .toBe(10) + }) + + it('returns the latest example summation', () => { + expect(getSubmissionExampleScore({ + reviewSummation: [ + { + aggregateScore: 10, + isExample: true, + updatedAt: '2026-06-17T01:00:00.000Z', + }, + { + aggregateScore: 15.25, + isExample: true, + updatedAt: '2026-06-17T02:00:00.000Z', + }, + ], + })) + .toBe(15.25) + }) + + it('returns failed example scorer scores', () => { + expect(getSubmissionExampleScore({ + reviewSummation: [ + { + aggregateScore: -1, + isExample: true, + metadata: { + testStatus: 'FAILED', + testType: 'example', + }, + }, + ], + })) + .toBe(-1) + }) + }) + + describe('getSubmissionTestProgress', () => { + it('returns example progress from marathon metadata test type', () => { + expect(getSubmissionTestProgress({ + reviewSummation: [ + { + aggregateScore: 10, + isExample: true, + metadata: { + testProgress: 1, + testStatus: 'SUCCESS', + testType: 'example', + }, + }, + ], + })) + .toEqual({ + process: 'example', + progressPercent: '100%', + status: 'SUCCESS', + }) + }) + }) + describe('getSubmissionProvisionalScore', () => { it('returns only provisional marathon scores', () => { expect(getSubmissionProvisionalScore({ @@ -125,6 +208,22 @@ describe('challenge utils', () => { })) .toBeUndefined() }) + + it('returns failed provisional scorer scores when requested', () => { + expect(getSubmissionProvisionalScore({ + reviewSummation: [ + { + aggregateScore: -1, + isProvisional: true, + metadata: { + testProcess: 'provisional', + testStatus: 'FAILED', + }, + }, + ], + }, true)) + .toBe(-1) + }) }) describe('getSubmissionSystemScore', () => { @@ -221,5 +320,21 @@ describe('challenge utils', () => { })) .toBeUndefined() }) + + it('returns failed system scorer scores when requested', () => { + expect(getSubmissionSystemScore({ + reviewSummation: [ + { + aggregateScore: -1, + isFinal: true, + metadata: { + testProcess: 'system', + testStatus: 'FAILED', + }, + }, + ], + }, true)) + .toBe(-1) + }) }) }) diff --git a/src/apps/work/src/lib/utils/challenge.utils.ts b/src/apps/work/src/lib/utils/challenge.utils.ts index 69dad2905..dfccc7731 100644 --- a/src/apps/work/src/lib/utils/challenge.utils.ts +++ b/src/apps/work/src/lib/utils/challenge.utils.ts @@ -14,6 +14,8 @@ interface SubmissionScore { provisionalScore?: number } +type MarathonMatchScoreProcess = 'example' | 'provisional' | 'system' + interface ScoredSubmissionLike { review?: Array<{ initialScore?: number @@ -30,7 +32,7 @@ interface ScoredSubmissionLike { * Display metadata for marathon test progress columns. */ export interface SubmissionTestProgressDisplay { - process?: 'provisional' | 'system' + process?: MarathonMatchScoreProcess progressPercent?: string status?: 'FAILED' | 'IN PROGRESS' | 'SUCCESS' } @@ -104,6 +106,14 @@ function toValidScore(value: unknown): number | undefined { : undefined } +function toFiniteScore(value: unknown): number | undefined { + const score = Number(value) + + return Number.isFinite(score) + ? score + : undefined +} + function getAverageScore(scores: Array): number | undefined { const validScores = scores .filter((score): score is number => score !== undefined) @@ -139,12 +149,14 @@ function getReviewSummationScoreTimestamp(entry: ReviewSummation): number { * Returns the latest valid score from matching Review API summations. * @param reviewSummation Review summations attached to a submission. * @param matcher Predicate that selects the requested Marathon Match score phase. + * @param allowNegativeScore Whether failed Marathon Match scorer scores like -1 should be retained. * @returns Latest valid aggregate score, or `undefined` when no matching score exists. * Used by marathon score display so relative-score rewrites replace stale raw scores. */ function getScoreFromSummation( reviewSummation: ReviewSummation[] | undefined, matcher: (entry: ReviewSummation) => boolean, + allowNegativeScore: boolean = false, ): number | undefined { if (!Array.isArray(reviewSummation) || !reviewSummation.length) { return undefined @@ -154,7 +166,9 @@ function getScoreFromSummation( .map((entry, index) => ({ entry, index, - score: toValidScore(entry.aggregateScore), + score: allowNegativeScore + ? toFiniteScore(entry.aggregateScore) + : toValidScore(entry.aggregateScore), timestamp: getReviewSummationScoreTimestamp(entry), })) .filter(item => item.score !== undefined && matcher(item.entry)) @@ -169,12 +183,12 @@ function getScoreFromSummation( /** * Resolves the marathon scoring phase represented by a review summation. * @param entry Review summation returned by Review API. - * @returns `provisional`, `system`, or `undefined` when the summation is not a scored marathon phase. + * @returns `example`, `provisional`, `system`, or `undefined` when the summation is not a scored marathon phase. * Used by strict marathon score helpers so progress/example reviews do not leak into score display. */ function getReviewSummationTestProcess( entry: ReviewSummation, -): 'provisional' | 'system' | undefined { +): MarathonMatchScoreProcess | undefined { const metadataProcess = normalizeTestProcess( entry.metadata?.testProcess ?? entry.metadata?.testType, @@ -193,6 +207,10 @@ function getReviewSummationTestProcess( return 'system' } + if (entry.isExample === true) { + return 'example' + } + if (entry.isFinal === true) { return 'system' } @@ -201,7 +219,7 @@ function getReviewSummationTestProcess( return 'provisional' } - if (entry.isFinal === false && entry.isExample !== true) { + if (entry.isFinal === false) { return 'provisional' } @@ -223,15 +241,19 @@ function getChallengeTypeName(type: string | ChallengeTypeRef | undefined): stri /** * Normalizes review summation metadata test process aliases for marathon display. * @param value Metadata process or legacy test type value from Review API. - * @returns `provisional` or `system` when the value identifies a tracked process. - * Used by `getSubmissionTestProgress` to avoid showing example-test metadata. + * @returns `example`, `provisional`, or `system` when the value identifies a tracked process. + * Used by `getSubmissionTestProgress` to display the active marathon validation phase. */ -function normalizeTestProcess(value: unknown): 'provisional' | 'system' | undefined { +function normalizeTestProcess(value: unknown): MarathonMatchScoreProcess | undefined { const normalized = typeof value === 'string' ? value.trim() .toLowerCase() : '' + if (normalized === 'example') { + return 'example' + } + if (normalized === 'system' || normalized === 'final') { return 'system' } @@ -280,6 +302,24 @@ function normalizeTestProgress(value: unknown): number | undefined { return Math.min(Math.max(progress, 0), 1) } +/** + * Assigns a stable ordering weight for marathon test processes. + * @param process Normalized marathon test process. + * @returns Numeric priority used to break ties between progress records. + * Used by `toSubmissionTestProgressCandidate` after timestamp and status ordering. + */ +function getTestProcessPriority(process: MarathonMatchScoreProcess | undefined): number { + if (process === 'system') { + return 2 + } + + if (process === 'provisional') { + return 1 + } + + return 0 +} + /** * Resolves the best timestamp available for ordering test progress summations. * @param entry Review summation containing marathon metadata. @@ -330,9 +370,7 @@ function toSubmissionTestProgressCandidate( ? 1 : 0, process, - processPriority: process === 'system' - ? 1 - : 0, + processPriority: getTestProcessPriority(process), progress, progressPercent: progress === undefined ? undefined @@ -466,6 +504,22 @@ export function is2RoundsChallenge(challenge: Pick): boolea return !!challenge.phases?.find(phase => phase.name === 'Checkpoint Submission') } +/** + * Returns only the example marathon score for a submission. + * @param submission Submission-like object containing Review API summations. + * @returns Example score, or `undefined` when example scoring has not produced a valid score. + * Used by marathon submission table display when the current validation process is Example. + */ +export function getSubmissionExampleScore( + submission: ScoredSubmissionLike, +): number | undefined { + return getScoreFromSummation( + submission.reviewSummation, + item => getReviewSummationTestProcess(item) === 'example', + true, + ) +} + export function getProvisionalScore(submission: ScoredSubmissionLike): number { return getSubmissionInitialScore(submission) } @@ -473,15 +527,18 @@ export function getProvisionalScore(submission: ScoredSubmissionLike): number { /** * Returns only the provisional marathon score for a submission. * @param submission Submission-like object containing legacy phase scores or Review API summations. + * @param allowNegativeScore Whether failed Marathon Match scorer scores like -1 should be retained. * @returns Provisional score, or `undefined` when provisional scoring has not produced a valid score. * Used by marathon submission score display to prefer latest relative summations over stale raw scores. */ export function getSubmissionProvisionalScore( submission: ScoredSubmissionLike, + allowNegativeScore: boolean = false, ): number | undefined { const provisionalSummationScore = getScoreFromSummation( submission.reviewSummation, item => getReviewSummationTestProcess(item) === 'provisional', + allowNegativeScore, ) if (provisionalSummationScore !== undefined) { return provisionalSummationScore @@ -531,15 +588,18 @@ export function getFinalScore(submission: ScoredSubmissionLike): number { /** * Returns only the system marathon score for a submission. * @param submission Submission-like object containing legacy phase scores or Review API summations. + * @param allowNegativeScore Whether failed Marathon Match scorer scores like -1 should be retained. * @returns System score, or `undefined` when system scoring has not produced a valid score. * Used by marathon submission score display to prefer latest relative summations over stale raw scores. */ export function getSubmissionSystemScore( submission: ScoredSubmissionLike, + allowNegativeScore: boolean = false, ): number | undefined { const systemSummationScore = getScoreFromSummation( submission.reviewSummation, item => getReviewSummationTestProcess(item) === 'system', + allowNegativeScore, ) if (systemSummationScore !== undefined) { return systemSummationScore diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 53b58a1c7..f513f8b09 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -59,17 +59,23 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `ChallengeNameField`: text input. - `ChallengeTrackField`: track selector from `useFetchChallengeTracks`. -- `ChallengeTypeField`: active type selector from `useFetchChallengeTypes`, excluding `Topgear Task` because that flow is not launchable from the work app editor. When the selected track is Design or QA, it also hides `Marathon Match` to match the legacy work-manager create flow and clears any now-invalid preselection. -- `ChallengeScheduleSection`: schedule editor for challenge start and phase dates. It keeps the detected timezone above the controls, renders the `Start Date` label with the `Scheduled` and `Immediately` start-mode radios aligned to the end of that header row above the input with a green selected state, persists the selected start mode in challenge metadata so saved `/edit` and `/view` routes reopen with the correct radio state, recalculates root phase dates when the challenge start changes, honors a completed predecessor phase's actual end date when deriving successor schedule rows, and keeps completed phases' end-date and duration controls locked to match legacy work-manager behavior. `Task` challenges hide this editable section across create, edit, and read-only view routes to match legacy work-manager behavior. +- `ChallengeTypeField`: active type selector from `useFetchChallengeTypes`, limited by the + `WORK_CREATE_CHALLENGE_TYPES_BY_TRACK` config. The default work-app allowlist is Design: + `Challenge`, `First2Finish`, `Task`; Development and Data Science: `Challenge`, `First2Finish`, + `Marathon Match`, `Task`; QA: `Challenge`, `First2Finish`, `Task`. Topgear Task, AI, AI + Engineering, and other active API-only/internal challenge types stay hidden from the create + dropdown, and any now-invalid preselection is cleared when the track changes. Deployments can + override the allowlist with `REACT_APP_WORK_CREATE_CHALLENGE_TYPES_BY_TRACK` JSON. +- `ChallengeScheduleSection`: schedule editor for challenge start and phase dates. It keeps the detected timezone above the controls, renders the `Start Date` label with the `Scheduled` and `Immediately` start-mode radios aligned to the end of that header row above the input with a green selected state, persists the selected start mode in challenge metadata so saved `/edit` and `/view` routes reopen with the correct radio state, recalculates root phase dates when the challenge start changes, honors completed phases' actual dates when deriving and displaying schedule rows, lets incomplete active Design phases be shortened no earlier than the current date/time, prevents incomplete active non-Design phases from being shortened, and keeps completed phases' end-date and duration controls locked to match legacy work-manager behavior. `Task` challenges hide this editable section across create, edit, and read-only view routes to match legacy work-manager behavior. - `DesignWorkTypeField`: shown for Design + Challenge, with the legacy work-type options (`Application Front-End Design`, `Print/Presentation`, `Web Design`, `Widget or Mobile Screen Design`, `Wireframes`). The selected value is stored in challenge tags. - `FunChallengeField`: shown for `Marathon Match` type and remains editable after creation so the form can switch between fun-challenge and standard marathon-match fields. -- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. On the human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. +- `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. On the human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. Design challenge manual reviewers always keep the public review opportunity checkbox disabled and unchecked. - `Submission Settings`: shown for Design `Challenge` and Design `First2Finish` types, and contains the final-deliverables, stock-art, and submission-visibility controls. - `FinalDeliverablesField`: design-challenge file-type editor that persists the legacy `fileTypes` metadata payload used on challenge draft pages. - `MaximumSubmissionsField`: non-visual compatibility field that rewrites the legacy `submissionLimit` metadata to the unlimited-only payload so design challenges no longer expose submission-cap controls. It defers dirtying that automatic normalization until the editor finishes its initial resource hydration, including the first render after asynchronously loaded challenge details arrive, which preserves copilot restoration before autosave/manual-save starts treating the metadata rewrite as a user change. - `ChallengeDescriptionField`: public markdown spec editor. - `ChallengePrivateDescriptionField`: optional private markdown spec editor. -- `TermsField`: advanced-option multi-select for challenge terms. The create route seeds the standard Topcoder terms entry automatically once the terms list loads, including immediately after the first draft-creation step assigns a challenge id, so the editor matches legacy work-manager defaults while still allowing the NDA toggle to add or remove the NDA term separately. +- `TermsField`: advanced-option multi-select for challenge terms. The create route seeds the standard Topcoder terms entry automatically once the terms list loads, including immediately after the first draft-creation step assigns a challenge id, so the editor matches legacy work-manager defaults while still allowing the NDA toggle to add or remove the NDA term separately. The seeded Standard Terms UUID can be set with `REACT_APP_DEFAULT_STANDARD_TERMS_UUID`, `DEFAULT_STANDARD_TERMS_UUID`, or `EnvironmentConfig.DEFAULT_STANDARD_TERMS_UUID`; legacy IDs and normalized titles are only fallback matching. - `ChallengeTagsField`: multi creatable tag picker excluding special challenge tags. - `ChallengeSkillsField`: async multi skills picker with billing-account-based required behavior. - `ChallengePrizesField`: placement-prize editor with an inline USD/POINTS selector that uses the challenge editor's green selected state, keeps the `Challenge Prizes` header on one line, and stays right-aligned above the fixed-width prize inputs. Each row always shows a numbered `Prize X` label, multi-prize setups allow tied lower placements while still rejecting prize increases for lower places, focused prize inputs keep focus while consecutive digits are entered, older payloads that omit the placement set are hydrated on demand, and only removable rows render the delete action so the first prize stays aligned with the selector. diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.component.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.component.spec.tsx index cc50cac1f..b424cbc3a 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.component.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.component.spec.tsx @@ -1,6 +1,7 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ import '@testing-library/jest-dom' import { + act, render, screen, waitFor, @@ -126,7 +127,25 @@ jest.mock('../../../../../lib/utils', () => ({ ? undefined : String(metadataEntry.value) }, - getPhaseDuration: () => 0, + getPhaseDuration: ( + startDate: Date | string, + endDate: Date | string, + ): number => { + const startTime = new Date(startDate) + .getTime() + const endTime = new Date(endDate) + .getTime() + + return Math.max( + 0, + Math.round((endTime - startTime) / 60_000), + ) + }, + getPhaseEndDateInDate: ( + startDate: Date | string, + durationMinutes: number, + ): Date => new Date(new Date(startDate) + .getTime() + durationMinutes * 60 * 1000), setMetadataValue: ( metadata: Array<{ name?: string @@ -188,6 +207,8 @@ interface TestHarnessProps { metadata?: ChallengeEditorFormData['metadata'] phases?: ChallengeEditorFormData['phases'] startDate?: ChallengeEditorFormData['startDate'] + status?: ChallengeEditorFormData['status'] + trackId?: string } const StartDateValue = (): JSX.Element => { @@ -235,7 +256,8 @@ const TestHarness = (props: TestHarnessProps): JSX.Element => { phases: props.phases || [], reviewers: [], startDate: props.startDate, - trackId: '', + status: props.status, + trackId: props.trackId || '', }, }) @@ -506,7 +528,7 @@ describe('ChallengeScheduleSection component', () => { .toEqual(expect.objectContaining({ isDurationEditable: false, isEndDateEditable: false, - isStartDateEditable: true, + isStartDateEditable: false, })) expect(checkpointReviewRow) .toEqual(expect.objectContaining({ @@ -515,6 +537,239 @@ describe('ChallengeScheduleSection component', () => { })) }) + it('displays completed phase actual dates and actual duration', () => { + render( + , + ) + + const checkpointReviewRow = [...mockPhaseEditorRow.mock.calls] + .map(([props]) => props as { + endDate?: string + phase?: { + duration?: number + name?: string + } + startDate?: string + }) + .reverse() + .find(props => props.phase?.name === 'Checkpoint Review') + + expect(checkpointReviewRow) + .toEqual(expect.objectContaining({ + endDate: '2026-04-09T13:14:00.000Z', + startDate: '2026-04-09T13:02:00.000Z', + })) + expect(checkpointReviewRow?.phase?.duration) + .toBe(12) + }) + + it('uses the current date as the minimum end date for active Design phases', () => { + mockUseFetchChallengeTracks.mockReturnValue({ + tracks: [{ + id: 'design-track', + name: 'Design', + track: 'DESIGN', + }], + }) + + render( + , + ) + + const reviewRow = [...mockPhaseEditorRow.mock.calls] + .map(([props]) => props as { + minEndDate?: Date + phase?: { + name?: string + } + }) + .reverse() + .find(props => props.phase?.name === 'Review') + + expect(reviewRow?.minEndDate?.toISOString()) + .toBe('2026-03-31T12:34:00.000Z') + }) + + it('uses the current scheduled end date as the minimum end date for active non-Design phases', () => { + mockUseFetchChallengeTracks.mockReturnValue({ + tracks: [{ + id: 'development-track', + name: 'Development', + track: 'DEVELOPMENT', + }], + }) + + render( + , + ) + + const reviewRow = [...mockPhaseEditorRow.mock.calls] + .map(([props]) => props as { + minEndDate?: Date + phase?: { + name?: string + } + }) + .reverse() + .find(props => props.phase?.name === 'Review') + + expect(reviewRow?.minEndDate?.toISOString()) + .toBe('2026-04-02T12:34:00.000Z') + }) + + it('keeps the active non-Design end-date minimum at the persisted end date after extending', async () => { + mockUseFetchChallengeTracks.mockReturnValue({ + tracks: [{ + id: 'development-track', + name: 'Development', + track: 'DEVELOPMENT', + }], + }) + + render( + , + ) + + const initialReviewRow = [...mockPhaseEditorRow.mock.calls] + .map(([props]) => props as { + index: number + onEndDateChange: (index: number, date: Date | null) => void + phase?: { + name?: string + } + }) + .reverse() + .find(props => props.phase?.name === 'Review') + + expect(initialReviewRow) + .toBeDefined() + + const reviewRowToUpdate = initialReviewRow as NonNullable + act(() => { + reviewRowToUpdate.onEndDateChange( + reviewRowToUpdate.index, + new Date('2026-04-04T12:34:00.000Z'), + ) + }) + + await waitFor(() => { + const reviewRow = [...mockPhaseEditorRow.mock.calls] + .map(([props]) => props as { + minEndDate?: Date + phase?: { + name?: string + } + }) + .reverse() + .find(props => props.phase?.name === 'Review') + + expect(reviewRow?.minEndDate?.toISOString()) + .toBe('2026-04-02T12:34:00.000Z') + }) + }) + + it('uses the persisted end date as the minimum for future non-Design phases in active challenges', () => { + mockUseFetchChallengeTracks.mockReturnValue({ + tracks: [{ + id: 'development-track', + name: 'Development', + track: 'DEVELOPMENT', + }], + }) + + render( + , + ) + + const reviewRow = [...mockPhaseEditorRow.mock.calls] + .map(([props]) => props as { + minEndDate?: Date + phase?: { + name?: string + } + }) + .reverse() + .find(props => props.phase?.name === 'Review') + + expect(reviewRow?.minEndDate?.toISOString()) + .toBe('2026-04-02T12:34:00.000Z') + }) + it('uses a completed predecessor actual end date for the submission row start time', () => { render( { expect(result) .toBe(true) }) + + it('does not allow editing completed submission phase start time', () => { + const result = canEditPhaseStartDate( + buildPhase({ + actualEndDate: '2026-04-09T12:51:00.000Z', + name: 'Submission', + }), + 1, + false, + ) + + expect(result) + .toBe(false) + }) }) describe('recalculatePhases', () => { @@ -101,6 +115,27 @@ describe('ChallengeScheduleSection helpers', () => { .toBe(updatedStartDate) }) + it('preserves an existing end date when the phase start is unchanged', () => { + const startDate = '2026-06-19T12:53:00.000Z' + const existingEndDate = '2026-06-24T13:52:00.000Z' + const phases: ChallengePhase[] = [ + buildPhase({ + duration: 120 * 60, + name: 'Registration', + phaseId: 'registration', + scheduledEndDate: existingEndDate, + scheduledStartDate: startDate, + }), + ] + + const result = recalculatePhases(phases, startDate) + + expect(result.phases[0]?.scheduledEndDate) + .toBe(existingEndDate) + expect(result.phases[0]?.duration) + .toBe((120 * 60) + 59) + }) + it('aligns successor phases to a predecessor actual end date when the predecessor closes early', () => { const checkpointReviewActualEnd = '2026-04-09T13:14:00.000Z' const phases: ChallengePhase[] = [ diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.tsx index 7202674a0..e5e1b60d7 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.tsx @@ -15,6 +15,7 @@ import { StartDateTimeInput, } from '../../../../../lib/components/form' import { + CHALLENGE_STATUS, CHALLENGE_TRACKS, } from '../../../../../lib/constants' import { @@ -29,6 +30,7 @@ import { canChangeDuration, getMetadataValue, getPhaseDuration, + getPhaseEndDateInDate, setMetadataValue, } from '../../../../../lib/utils' import { PhaseEditorRow } from '../PhaseEditorRow' @@ -99,6 +101,118 @@ function noopVirtualPhaseChange(): void { // Display-only schedule rows do not mutate persisted phase state. } +/** + * Returns the latest valid date from a candidate list. + * + * @param dates candidate date values. + * @returns the latest date, or `undefined` when no valid date is provided. + */ +function getLatestDate(dates: Array): Date | undefined { + return dates.reduce((latestDate, date) => { + if (!date) { + return latestDate + } + + if (!latestDate || date.getTime() > latestDate.getTime()) { + return date + } + + return latestDate + }, undefined) +} + +/** + * Resolves the minimum allowed phase end date for schedule edits. + * + * @param phase phase currently being edited. + * @param phaseStartDate resolved phase start date. + * @param isDesignTrackChallenge whether the challenge is in the Design track. + * @param minScheduleDate current schedule floor used by the editor. + * @param persistedScheduledEndDate phase end date captured when the editor opened. + * @param isActiveChallenge whether the challenge is active. + * @returns minimum allowed end date for the phase. + */ +function getMinimumPhaseEndDate( + phase: ChallengePhase, + phaseStartDate: Date | undefined, + isDesignTrackChallenge: boolean, + minScheduleDate: Date, + persistedScheduledEndDate: Date | string | undefined, + isActiveChallenge: boolean, +): Date | undefined { + if ((phase.isOpen || isActiveChallenge) && !phase.actualEndDate) { + const currentEndDate = toDate(persistedScheduledEndDate) + || toDate(phase.scheduledEndDate) + + return getLatestDate( + isDesignTrackChallenge + ? [ + phaseStartDate, + minScheduleDate, + ] + : [ + currentEndDate, + phaseStartDate, + minScheduleDate, + ], + ) + } + + return phaseStartDate || minScheduleDate +} + +/** + * Resolves the validation message for edits before the phase end-date minimum. + * + * @param phase phase currently being edited. + * @param isDesignTrackChallenge whether the challenge is in the Design track. + * @param isActiveChallenge whether the challenge is active. + * @returns validation message shown below the phase end-date control. + */ +function getMinimumPhaseEndDateError( + phase: ChallengePhase, + isDesignTrackChallenge: boolean, + isActiveChallenge: boolean, +): string { + if ((phase.isOpen || isActiveChallenge) && isDesignTrackChallenge) { + return 'End date must be at or after the current date/time.' + } + + if (phase.isOpen || isActiveChallenge) { + return 'Active phase end date cannot be shortened for this track.' + } + + return 'End date must be after start date.' +} + +/** + * Builds a display-only copy of a completed phase using actual phase dates. + * + * @param phase phase currently rendered. + * @returns phase values for the schedule row display. + */ +function getDisplayPhase(phase: ChallengePhase): ChallengePhase { + const actualEndDate = toDate(phase.actualEndDate) + + if (!actualEndDate) { + return phase + } + + const actualStartDate = toDate(phase.actualStartDate) + const scheduledStartDate = toDate(phase.scheduledStartDate) + const displayStartDate = actualStartDate || scheduledStartDate + const displayDuration = displayStartDate + ? getPhaseDuration(displayStartDate, actualEndDate) + : phase.duration + + return { + ...phase, + duration: displayDuration, + scheduledEndDate: actualEndDate.toISOString(), + scheduledStartDate: displayStartDate?.toISOString() || phase.scheduledStartDate, + } +} + // eslint-disable-next-line complexity export const ChallengeScheduleSection: FC = ( props: ChallengeScheduleSectionProps, @@ -110,6 +224,7 @@ export const ChallengeScheduleSection: FC = ( const challengeTrackResult = useFetchChallengeTracks() const challengeTracks = challengeTrackResult.tracks const trackId = formContext.watch('trackId') as string | undefined + const challengeStatus = formContext.watch('status') as string | undefined const useSchedulingApi = formContext.watch('legacy.useSchedulingAPI') as boolean | undefined const metadata = formContext.watch('metadata') as ChallengeMetadata[] | undefined const startDate = formContext.watch('startDate') as Date | string | undefined @@ -170,6 +285,11 @@ export const ChallengeScheduleSection: FC = ( }, [selectedChallengeTrack], ) + const isActiveChallenge = useMemo( + (): boolean => challengeStatus?.trim() + .toUpperCase() === CHALLENGE_STATUS.ACTIVE, + [challengeStatus], + ) const hasCheckpointSubmissionPhase = useMemo( (): boolean => phases.some(phase => normalizePhaseName(phase.name) === 'checkpoint submission'), [phases], @@ -194,6 +314,7 @@ export const ChallengeScheduleSection: FC = ( const initializedRef = useRef(false) const lastInternalStartDateValueRef = useRef() + const initialPhaseEndDatesRef = useRef>(new Map()) const phaseStartOverridesRef = useRef>(new Map()) const prunePhaseStartOverrides = useCallback((nextPhases: ChallengePhase[]): void => { @@ -213,6 +334,20 @@ export const ChallengeScheduleSection: FC = ( }) }, []) + useEffect(() => { + phases.forEach((phase, index) => { + const phaseKey = getPhaseKey(phase, index) + if (initialPhaseEndDatesRef.current.has(phaseKey)) { + return + } + + const scheduledEndDate = toDate(phase.scheduledEndDate) + if (scheduledEndDate) { + initialPhaseEndDatesRef.current.set(phaseKey, scheduledEndDate.toISOString()) + } + }) + }, [phases]) + const applyPhases = useCallback( ( nextPhases: ChallengePhase[], @@ -379,20 +514,70 @@ export const ChallengeScheduleSection: FC = ( const handleDurationChange = useCallback( (index: number, durationMinutes: number): void => { - const nextPhases = phases.map((phase, phaseIndex) => { + const phase = phases[index] + if (!phase) { + return + } + + const phaseKey = getPhaseKey(phase, index) + const phaseStartDate = toDate(phase.scheduledStartDate) + const nextDuration = normalizeDuration(durationMinutes) + const nextEndDate = phaseStartDate + ? getPhaseEndDateInDate(phaseStartDate, nextDuration) + : undefined + const persistedScheduledEndDate = initialPhaseEndDatesRef.current.get(phaseKey) + const minimumEndDate = getMinimumPhaseEndDate( + phase, + phaseStartDate, + isDesignTrackChallenge, + minScheduleDate, + persistedScheduledEndDate, + isActiveChallenge, + ) + + if ( + minimumEndDate + && nextEndDate + && nextEndDate.getTime() < minimumEndDate.getTime() + ) { + setPhaseEndDateErrors(previousState => ({ + ...previousState, + [phaseKey]: getMinimumPhaseEndDateError( + phase, + isDesignTrackChallenge, + isActiveChallenge, + ), + })) + return + } + + setPhaseEndDateErrors(previousState => { + const nextState = { ...previousState } + delete nextState[phaseKey] + return nextState + }) + + const nextPhases = phases.map((currentPhase, phaseIndex) => { if (phaseIndex !== index) { - return phase + return currentPhase } return { - ...phase, - duration: normalizeDuration(durationMinutes), + ...currentPhase, + duration: nextDuration, + scheduledEndDate: nextEndDate?.toISOString() || currentPhase.scheduledEndDate, } }) applyPhases(nextPhases) }, - [applyPhases, phases], + [ + applyPhases, + isActiveChallenge, + isDesignTrackChallenge, + minScheduleDate, + phases, + ], ) const handlePhaseStartDateChange = useCallback( (index: number, date: Date | null): void => { @@ -440,6 +625,7 @@ export const ChallengeScheduleSection: FC = ( const phaseKey = getPhaseKey(phase, index) const phaseStartDate = toDate(phase.scheduledStartDate) + const persistedScheduledEndDate = initialPhaseEndDatesRef.current.get(phaseKey) if (!phaseStartDate) { setPhaseEndDateErrors(previousState => ({ @@ -457,6 +643,35 @@ export const ChallengeScheduleSection: FC = ( return } + const minimumEndDate = getMinimumPhaseEndDate( + phase, + phaseStartDate, + isDesignTrackChallenge, + minScheduleDate, + persistedScheduledEndDate, + isActiveChallenge, + ) + if ( + minimumEndDate + && nextEndDate.getTime() < minimumEndDate.getTime() + ) { + setPhaseEndDateErrors(previousState => ({ + ...previousState, + [phaseKey]: getMinimumPhaseEndDateError( + phase, + isDesignTrackChallenge, + isActiveChallenge, + ), + })) + return + } + + setPhaseEndDateErrors(previousState => { + const nextState = { ...previousState } + delete nextState[phaseKey] + return nextState + }) + const nextPhases = phases.map((currentPhase, phaseIndex) => { if (phaseIndex !== index) { return currentPhase @@ -469,12 +684,19 @@ export const ChallengeScheduleSection: FC = ( return { ...currentPhase, duration: nextDuration, + scheduledEndDate: nextEndDate.toISOString(), } }) applyPhases(nextPhases) }, - [applyPhases, phases], + [ + applyPhases, + isActiveChallenge, + isDesignTrackChallenge, + minScheduleDate, + phases, + ], ) const handleStartDateModeChange = useCallback( @@ -662,26 +884,35 @@ export const ChallengeScheduleSection: FC = ( const index = row.actualIndex const phaseStartDate = toDate(phase.scheduledStartDate) const isDurationEditable = canChangeDuration(phase) + const phaseKey = getPhaseKey(phase, index) + const displayPhase = getDisplayPhase(phase) return ( ) }) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.utils.ts b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.utils.ts index 44999188e..10ae97b46 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.utils.ts +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.utils.ts @@ -3,7 +3,10 @@ import { PHASE_DURATION_MIN_MINUTES, } from '../../../../../lib/constants/challenge-editor.constants' import { ChallengePhase } from '../../../../../lib/models' -import { getPhaseEndDateInDate } from '../../../../../lib/utils/date.utils' +import { + getPhaseDuration, + getPhaseEndDateInDate, +} from '../../../../../lib/utils/date.utils' export const AI_SCREENING_PHASE_NAME = 'AI Screening' export const AI_REVIEW_PHASE_NAME = 'AI Review' @@ -92,6 +95,10 @@ export function canEditPhaseStartDate( index: number, isTwoRoundDesignChallenge: boolean, ): boolean { + if (phase.actualEndDate) { + return false + } + const normalizedPhaseName = normalizePhaseName(phase.name) const isRegistrationPhase = normalizedPhaseName === 'registration' const isStandardSubmissionPhase = normalizedPhaseName === 'submission' @@ -204,8 +211,9 @@ export function recalculatePhases( // eslint-disable-next-line complexity for (let index = 0; index < phases.length; index += 1) { const phase = phases[index] - const duration = normalizeDuration(phase.duration) + let duration = normalizeDuration(phase.duration) const existingPhaseStartDate = toDate(phase.scheduledStartDate) + const existingPhaseEndDate = toDate(phase.scheduledEndDate) let phaseStartDate = shouldScheduleDates || index === 0 ? baseStartDate : existingPhaseStartDate || baseStartDate @@ -230,9 +238,21 @@ export function recalculatePhases( phaseStartDate = overriddenStartDate } - const phaseEndDate = phaseStartDate - ? getPhaseEndDateInDate(phaseStartDate, duration) - : undefined + const hasUnchangedStartDate = !!existingPhaseStartDate + && !!phaseStartDate + && existingPhaseStartDate.getTime() === phaseStartDate.getTime() + const shouldPreserveScheduledEndDate = !!existingPhaseEndDate + && hasUnchangedStartDate + && !shouldScheduleDates + const phaseEndDate = shouldPreserveScheduledEndDate + ? existingPhaseEndDate + : phaseStartDate + ? getPhaseEndDateInDate(phaseStartDate, duration) + : undefined + + if (phaseStartDate && phaseEndDate) { + duration = normalizeDuration(getPhaseDuration(phaseStartDate, phaseEndDate)) + } const nextPhase: ChallengePhase = { ...phase, diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.spec.ts b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.spec.ts index 86ee57fe4..a8b6d6eed 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.spec.ts +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.spec.ts @@ -89,7 +89,16 @@ describe('getChallengeTypeFilterTrack', () => { name: 'Quality Assurance', track: 'QA', }))) - .toBe('QUALITY_ASSURANCE') + .toBe('QA') + }) + + it('normalizes development aliases', () => { + expect(getChallengeTypeFilterTrack(buildTrack({ + abbreviation: 'DEV', + name: 'Development', + track: 'Development', + }))) + .toBe('DEVELOP') }) it('returns an empty string when no track is selected', () => { @@ -99,7 +108,7 @@ describe('getChallengeTypeFilterTrack', () => { }) describe('buildChallengeTypeOptions', () => { - it('keeps only active launchable challenge types and sorts them by label', () => { + it('keeps only active configured challenge types and sorts them by label', () => { const options = buildChallengeTypeOptions([ buildChallengeType({ abbreviation: 'TGT', @@ -116,6 +125,21 @@ describe('buildChallengeTypeOptions', () => { id: 'challenge-id', name: 'Challenge', }), + buildChallengeType({ + abbreviation: 'AI', + id: 'ai-id', + name: 'AI', + }), + buildChallengeType({ + abbreviation: 'AIE', + id: 'ai-engineering-id', + name: 'AI Engineering', + }), + buildChallengeType({ + abbreviation: 'TEST', + id: 'test-id', + name: 'test11775112200655', + }), buildChallengeType({ abbreviation: 'MM', id: 'inactive-marathon-match-id', @@ -149,11 +173,26 @@ describe('buildChallengeTypeOptions', () => { id: 'marathon-match-id', name: 'Marathon Match', }), + buildChallengeType({ + abbreviation: 'F2F', + id: 'first-2-finish-id', + name: 'First2Finish', + }), buildChallengeType({ abbreviation: 'TSK', id: 'task-id', name: 'Task', }), + buildChallengeType({ + abbreviation: 'AI', + id: 'ai-id', + name: 'AI', + }), + buildChallengeType({ + abbreviation: 'AIE', + id: 'ai-engineering-id', + name: 'AI Engineering', + }), ], buildTrack({ name: 'Design', track: 'DESIGN', @@ -165,6 +204,10 @@ describe('buildChallengeTypeOptions', () => { label: 'Challenge', value: 'challenge-id', }, + { + label: 'First2Finish', + value: 'first-2-finish-id', + }, { label: 'Task', value: 'task-id', @@ -184,6 +227,16 @@ describe('buildChallengeTypeOptions', () => { id: 'marathon-match-id', name: 'Marathon Match', }), + buildChallengeType({ + abbreviation: 'F2F', + id: 'first-2-finish-id', + name: 'First2Finish', + }), + buildChallengeType({ + abbreviation: 'TSK', + id: 'task-id', + name: 'Task', + }), ], buildTrack({ abbreviation: 'QA', name: 'Quality Assurance', @@ -196,6 +249,92 @@ describe('buildChallengeTypeOptions', () => { label: 'Challenge', value: 'challenge-id', }, + { + label: 'First2Finish', + value: 'first-2-finish-id', + }, + { + label: 'Task', + value: 'task-id', + }, + ]) + }) + + it('shows Marathon Match for Development tracks', () => { + const options = buildChallengeTypeOptions([ + buildChallengeType({ + abbreviation: 'CH', + id: 'challenge-id', + name: 'Challenge', + }), + buildChallengeType({ + abbreviation: 'F2F', + id: 'first-2-finish-id', + name: 'First2Finish', + }), + buildChallengeType({ + abbreviation: 'MM', + id: 'marathon-match-id', + name: 'Marathon Match', + }), + buildChallengeType({ + abbreviation: 'TSK', + id: 'task-id', + name: 'Task', + }), + ], buildTrack({ + name: 'Development', + track: 'Development', + })) + + expect(options) + .toEqual([ + { + label: 'Challenge', + value: 'challenge-id', + }, + { + label: 'First2Finish', + value: 'first-2-finish-id', + }, + { + label: 'Marathon Match', + value: 'marathon-match-id', + }, + { + label: 'Task', + value: 'task-id', + }, + ]) + }) + + it('shows Marathon Match for Data Science tracks', () => { + const options = buildChallengeTypeOptions([ + buildChallengeType({ + abbreviation: 'CH', + id: 'challenge-id', + name: 'Challenge', + }), + buildChallengeType({ + abbreviation: 'MM', + id: 'marathon-match-id', + name: 'Marathon Match', + }), + ], buildTrack({ + name: 'Data Science', + track: 'DATA_SCIENCE', + })) + + expect(options) + .toEqual([ + { + label: 'Challenge', + value: 'challenge-id', + }, + { + label: 'Marathon Match', + value: 'marathon-match-id', + }, ]) }) }) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.ts b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.ts index c5c018cdd..0e136b9a4 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.ts +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeTypeField.utils.ts @@ -1,19 +1,26 @@ import { FormSelectOption } from '../../../../lib/components/form' +import { CREATE_CHALLENGE_TYPES_BY_TRACK } from '../../../../lib/constants/challenge-editor.constants' import { ChallengeType, Track, } from '../../../../lib/models' const TOPGEAR_TASK_ABBREVIATION = 'TGT' -const TOPGEAR_TASK_NAME = 'topgeartask' +const TOPGEAR_TASK_NAME = 'TOPGEARTASK' const MARATHON_MATCH_ABBREVIATION = 'MM' -const MARATHON_MATCH_NAME = 'marathonmatch' +const MARATHON_MATCH_NAME = 'MARATHONMATCH' const TRACK_ALIASES: Record = { + DATA_SCIENCE: 'DATA_SCIENCE', + DATASCIENCE: 'DATA_SCIENCE', DES: 'DESIGN', DESIGN: 'DESIGN', - QA: 'QUALITY_ASSURANCE', - QUALITY_ASSURANCE: 'QUALITY_ASSURANCE', - QUALITYASSURANCE: 'QUALITY_ASSURANCE', + DEV: 'DEVELOP', + DEVELOP: 'DEVELOP', + DEVELOPMENT: 'DEVELOP', + DS: 'DATA_SCIENCE', + QA: 'QA', + QUALITY_ASSURANCE: 'QA', + QUALITYASSURANCE: 'QA', } function normalizeTrackToken(value: unknown): string { @@ -27,13 +34,111 @@ function normalizeTrackToken(value: unknown): string { .replace(/\s+/g, '_') } +function normalizeChallengeTypeToken(value: unknown): string { + if (value === undefined || value === null) { + return '' + } + + return String(value) + .trim() + .toUpperCase() + .replaceAll(/[\s_-]+/g, '') +} + +/** + * Resolves a track config key to the canonical token used by work-app challenge type filters. + * + * @param track track key from `CREATE_CHALLENGE_TYPES_BY_TRACK`. + * @returns canonical track token, or an empty string when the key is blank. + * @remarks Used while loading create-challenge type allowlist config so deployment config can use + * either API tokens such as `DEVELOP` and `QA` or display tokens such as `Development`. + * @throws Does not throw. + */ +function getConfiguredTrackToken(track: string): string { + const normalizedTrack = normalizeTrackToken(track) + + if (!normalizedTrack) { + return '' + } + + return TRACK_ALIASES[normalizedTrack] || normalizedTrack +} + +/** + * Converts configured challenge type names into normalized tokens keyed by canonical track. + * + * @param challengeTypesByTrack environment-backed create-challenge type allowlist. + * @returns normalized challenge type tokens grouped by normalized track token. + * @remarks Used by `buildChallengeTypeOptions` to compare API challenge types against configured + * display names without depending on challenge type ids. + * @throws Does not throw. + */ +function buildAllowedChallengeTypeTokensByTrack( + challengeTypesByTrack: Record, +): Record> { + return Object.entries(challengeTypesByTrack) + .reduce>>((result, [track, challengeTypeNames]) => { + const normalizedTrack = getConfiguredTrackToken(track) + + if (!normalizedTrack) { + return result + } + + const typeTokens = challengeTypeNames + .map(normalizeChallengeTypeToken) + .filter(Boolean) + + if (typeTokens.length < 1) { + return result + } + + result[normalizedTrack] = new Set([ + ...Array.from(result[normalizedTrack] || []), + ...typeTokens, + ]) + + return result + }, {}) +} + +const ALLOWED_CHALLENGE_TYPE_TOKENS_BY_TRACK = buildAllowedChallengeTypeTokensByTrack( + CREATE_CHALLENGE_TYPES_BY_TRACK, +) + +const ALL_ALLOWED_CHALLENGE_TYPE_TOKENS = new Set( + Object.values(ALLOWED_CHALLENGE_TYPE_TOKENS_BY_TRACK) + .flatMap(typeTokens => Array.from(typeTokens)), +) + +/** + * Gets the configured create-challenge type tokens for a selected track. + * + * @param challengeTrack selected challenge track metadata from `useFetchChallengeTracks`. + * @returns allowed challenge type tokens for the selected track; when no track is selected, the + * union of configured type tokens across all tracks is returned. + * @remarks Used by the work challenge editor so AI-only rating challenge types and other internal + * API rows are not exposed in the create challenge dropdown. + * @throws Does not throw. + */ +function getAllowedChallengeTypeTokens( + challengeTrack?: Track, +): ReadonlySet { + const normalizedTrack = getChallengeTypeFilterTrack(challengeTrack) + + if (!normalizedTrack) { + return ALL_ALLOWED_CHALLENGE_TYPE_TOKENS + } + + return ALLOWED_CHALLENGE_TYPE_TOKENS_BY_TRACK[normalizedTrack] || new Set() +} + /** * Resolves challenge track metadata to the canonical track token used by work-manager filters. * * @param challengeTrack challenge track metadata returned by `useFetchChallengeTracks`. * @returns normalized track token, or an empty string when no track is selected. - * @remarks Used by the work challenge editor to keep challenge-type filters aligned with the - * legacy work-manager create flow for Design and QA tracks. + * @remarks Used by the work challenge editor to apply track-specific create-challenge type + * allowlists while tolerating challenge-track API naming differences. * @throws Does not throw. */ export function getChallengeTypeFilterTrack( @@ -61,13 +166,8 @@ export function getChallengeTypeFilterTrack( * @throws Does not throw. */ export function isTopgearTaskChallengeType(challengeType: ChallengeType): boolean { - const normalizedAbbreviation = (challengeType.abbreviation || '') - .trim() - .toUpperCase() - const normalizedName = (challengeType.name || '') - .replaceAll(/\s+/g, '') - .trim() - .toLowerCase() + const normalizedAbbreviation = normalizeChallengeTypeToken(challengeType.abbreviation) + const normalizedName = normalizeChallengeTypeToken(challengeType.name) return normalizedAbbreviation === TOPGEAR_TASK_ABBREVIATION || normalizedName === TOPGEAR_TASK_NAME @@ -79,30 +179,42 @@ export function isTopgearTaskChallengeType(challengeType: ChallengeType): boolea * @param challengeType challenge type metadata returned by the challenge types API. * @returns `true` when the type matches Marathon Match by abbreviation or normalized name; * otherwise `false`. - * @remarks Used by the work challenge editor to hide Marathon Match for Design and QA tracks - * while leaving the type available for tracks that still support it. + * @remarks Used by work challenge editor helpers that need Marathon Match-specific behavior. * @throws Does not throw. */ export function isMarathonMatchChallengeType(challengeType: ChallengeType): boolean { - const normalizedAbbreviation = (challengeType.abbreviation || '') - .trim() - .toUpperCase() - const normalizedName = (challengeType.name || '') - .replaceAll(/\s+/g, '') - .trim() - .toLowerCase() + const normalizedAbbreviation = normalizeChallengeTypeToken(challengeType.abbreviation) + const normalizedName = normalizeChallengeTypeToken(challengeType.name) return normalizedAbbreviation === MARATHON_MATCH_ABBREVIATION || normalizedName === MARATHON_MATCH_NAME } +/** + * Determines whether a challenge type is included in the create-challenge allowlist. + * + * @param challengeType challenge type metadata returned by the challenge types API. + * @param allowedTypeTokens configured type tokens for the selected challenge track. + * @returns `true` when the challenge type matches by normalized name or abbreviation. + * @remarks Used by the work challenge editor to hide rating-specific and internal challenge types + * that may be active in the challenge API but should not be launched from platform-ui. + * @throws Does not throw. + */ +function isAllowedCreateChallengeType( + challengeType: ChallengeType, + allowedTypeTokens: ReadonlySet, +): boolean { + return allowedTypeTokens.has(normalizeChallengeTypeToken(challengeType.name)) + || allowedTypeTokens.has(normalizeChallengeTypeToken(challengeType.abbreviation)) +} + /** * Builds the launchable challenge type options shown in the challenge editor. * * @param challengeTypes full challenge type metadata returned by `useFetchChallengeTypes`. * @param challengeTrack selected challenge track metadata from `useFetchChallengeTracks`. - * @returns active challenge types, excluding Topgear Task, sorted by display name and mapped - * to select options. Design and QA tracks also exclude Marathon Match to match work-manager. + * @returns active challenge types included in the configured create-challenge allowlist, sorted by + * display name and mapped to select options. Topgear Task remains hidden even if API data is active. * @remarks Used exclusively by `ChallengeTypeField` in the work app so users can only launch * supported challenge types from this editor. * @throws Does not throw. @@ -111,13 +223,14 @@ export function buildChallengeTypeOptions( challengeTypes: ChallengeType[], challengeTrack?: Track, ): FormSelectOption[] { - const normalizedTrack = getChallengeTypeFilterTrack(challengeTrack) - const shouldHideMarathonMatch = normalizedTrack === 'DESIGN' - || normalizedTrack === 'QUALITY_ASSURANCE' + const allowedTypeTokens = getAllowedChallengeTypeTokens(challengeTrack) return challengeTypes - .filter(type => type.isActive && !isTopgearTaskChallengeType(type)) - .filter(type => !shouldHideMarathonMatch || !isMarathonMatchChallengeType(type)) + .filter(type => ( + type.isActive + && !isTopgearTaskChallengeType(type) + && isAllowedCreateChallengeType(type, allowedTypeTokens) + )) .sort((typeA, typeB) => typeA.name.localeCompare(typeB.name)) .map(type => ({ label: type.name, diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss index 0925117e9..0fced2607 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.module.scss @@ -343,6 +343,75 @@ gap: 16px; } +.resultGrid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); +} + +.resultItem, +.resultMeta { + background: #f6f8fa; + border: 1px solid $black-10; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; +} + +.resultItem span, +.resultMeta span, +.resultJsonSection span { + color: $black-60; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.resultItem strong, +.resultMeta strong { + color: $black-80; + font-size: 14px; + overflow-wrap: anywhere; +} + +.resultMessage { + background: #eef4ff; + border: 1px solid #bfd0f9; + border-radius: 8px; + color: #2f4b84; + font-size: 14px; + line-height: 1.5; + padding: 12px; +} + +.resultLink { + color: $link-blue-dark; + font-weight: 600; +} + +.resultJsonSection { + display: flex; + flex-direction: column; + gap: 8px; +} + +.resultJsonSection pre { + background: #101820; + border-radius: 8px; + color: #f3f7fb; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + line-height: 1.5; + margin: 0; + max-height: 300px; + overflow: auto; + padding: 12px; + white-space: pre-wrap; + word-break: break-word; +} + .compilationErrorMeta { display: flex; flex-direction: column; diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx index 370689417..dbacab6fa 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.tsx @@ -21,12 +21,14 @@ import { MarathonMatchTester, MarathonMatchTesterSummary, MarathonMatchTestSubmissionResponse, + MarathonMatchTestSubmissionStatusResponse, UpdateMarathonMatchConfigInput, } from '../../../../../lib/models' import { createMarathonMatchConfig, fetchMarathonMatchConfig, fetchMarathonMatchDefaults, + fetchMarathonMatchTestSubmissionStatus, fetchTester, fetchTesters, rerunMarathonMatchScores, @@ -34,6 +36,7 @@ import { uploadMarathonMatchTestSubmission, } from '../../../../../lib/services' import { + formatDateTime, showErrorToast, showInfoToast, showSuccessToast, @@ -45,6 +48,7 @@ import styles from './MarathonMatchScorerSection.module.scss' const DEFAULT_SCORER_NAME = 'Marathon Match Scorer' const DEFAULT_SCORE_DIRECTION = 'MAXIMIZE' const POLL_INTERVAL_MS = 5000 +const TEST_SUBMISSION_TERMINAL_STATUSES = new Set(['SUCCESS', 'FAILED']) const PHASE_LABELS = { example: 'Example', provisional: 'Provisional', @@ -119,6 +123,106 @@ interface TestSubmissionPhaseOption { value: MarathonMatchConfigType } +type TestSubmissionResultState = + | MarathonMatchTestSubmissionResponse + | MarathonMatchTestSubmissionStatusResponse + | undefined + +/** + * Normalizes a validation submission status for comparisons and display. + * @param status Raw status returned by marathon-match-api. + * @returns Uppercase status text, or `UNKNOWN` when absent. + * Used by validation-run polling and modal rendering. + */ +function normalizeTestSubmissionStatus(status?: string): string { + const normalizedStatus = status?.trim() + .toUpperCase() + + return normalizedStatus || 'UNKNOWN' +} + +/** + * Checks whether validation submission polling should stop. + * @param status Raw status returned by marathon-match-api. + * @returns True when the validation run reached a terminal state. + * Used by `pollTestSubmissionStatus` after each status response. + */ +function isTerminalTestSubmissionStatus(status?: string): boolean { + return TEST_SUBMISSION_TERMINAL_STATUSES.has(normalizeTestSubmissionStatus(status)) +} + +/** + * Formats a numeric score for the validation result modal. + * @param score Optional final aggregate score. + * @returns Display-ready score text. + * Used by `renderTestSubmissionResultModal`. + */ +function formatTestSubmissionScore(score?: number): string { + return typeof score === 'number' && Number.isFinite(score) + ? score.toLocaleString(undefined, { maximumFractionDigits: 6 }) + : 'Not available' +} + +/** + * Formats a validation-run progress value as a percentage. + * @param progress Optional progress value from 0 to 1. + * @returns Display-ready progress text. + * Used by inline validation status and the result modal. + */ +function formatTestSubmissionProgress(progress?: number): string { + if (typeof progress !== 'number' || !Number.isFinite(progress)) { + return 'Pending' + } + + return `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%` +} + +/** + * Formats completed/total/failed testcase counts. + * @param result Validation run status response with optional testcase counters. + * @returns Compact testcase count summary. + * Used by `renderTestSubmissionResultModal`. + */ +function formatTestSubmissionTests(result: MarathonMatchTestSubmissionStatusResponse): string { + const completed = result.completedTests ?? 0 + const total = result.totalTests ?? 0 + const failed = result.failedTests ?? 0 + + if (!total) { + return failed + ? `${failed} failed` + : 'Not available' + } + + return `${completed}/${total} complete${failed ? `, ${failed} failed` : ''}` +} + +/** + * Formats validation-run metadata for a read-only modal preview. + * @param value Optional metadata object or array returned by the scorer. + * @returns Pretty JSON text, or an empty string when no metadata exists. + * Used by `renderJsonPreview`. + */ +function formatJsonPreview(value?: Record | Record[]): string { + if (!value) { + return '' + } + + return JSON.stringify(value, undefined, 2) +} + +/** + * Checks whether a validation result includes full status fields. + * @param result Upload response or status response stored in component state. + * @returns True when the response has final/polling fields from the status endpoint. + * Used before rendering the validation result modal. + */ +function isTestSubmissionStatusResponse( + result: MarathonMatchTestSubmissionResponse | MarathonMatchTestSubmissionStatusResponse | undefined, +): result is MarathonMatchTestSubmissionStatusResponse { + return !!result && 'fileName' in result +} + interface PhaseConfigCardProps { errors: PhaseValidationErrors label: string @@ -736,6 +840,7 @@ export const MarathonMatchScorerSection: FC = ( const phaseListRef = useRef(phases) const isMountedRef = useRef(true) const pollingTimerRef = useRef() + const testSubmissionPollingTimerRef = useRef() const [config, setConfig] = useState() const [defaults, setDefaults] = useState() @@ -764,7 +869,8 @@ export const MarathonMatchScorerSection: FC = ( const [testerLoadError, setTesterLoadError] = useState() const [selectedTestSubmissionFile, setSelectedTestSubmissionFile] = useState() const [testSubmissionConfigType, setTestSubmissionConfigType] = useState('PROVISIONAL') - const [testSubmissionResult, setTestSubmissionResult] = useState() + const [testSubmissionResult, setTestSubmissionResult] = useState() + const [showTestSubmissionResultModal, setShowTestSubmissionResultModal] = useState(false) const [showNewTesterModal, setShowNewTesterModal] = useState(false) const [showNewVersionModal, setShowNewVersionModal] = useState(false) const [showCompilationErrorsModal, setShowCompilationErrorsModal] = useState(false) @@ -847,6 +953,20 @@ export const MarathonMatchScorerSection: FC = ( pollingTimerRef.current = undefined }, []) + /** + * Clears the active validation-submission polling timeout. + * @returns void + * Used when a new validation upload starts, the selected file changes, or the component unmounts. + */ + const clearTestSubmissionPollingTimer = useCallback((): void => { + if (testSubmissionPollingTimerRef.current === undefined) { + return + } + + window.clearTimeout(testSubmissionPollingTimerRef.current) + testSubmissionPollingTimerRef.current = undefined + }, []) + const handleOpenNewTesterModal = useCallback((): void => { setShowNewTesterModal(true) }, []) @@ -1181,8 +1301,10 @@ export const MarathonMatchScorerSection: FC = ( setSelectedTestSubmissionFile(nextFile) setTestSubmissionError(undefined) setTestSubmissionResult(undefined) + setShowTestSubmissionResultModal(false) + clearTestSubmissionPollingTimer() }, - [], + [clearTestSubmissionPollingTimer], ) useEffect(() => { @@ -1191,7 +1313,8 @@ export const MarathonMatchScorerSection: FC = ( useEffect(() => () => { isMountedRef.current = false - }, []) + clearTestSubmissionPollingTimer() + }, [clearTestSubmissionPollingTimer]) useEffect(() => { let isMounted = true @@ -1203,7 +1326,9 @@ export const MarathonMatchScorerSection: FC = ( setRerunError(undefined) setTestSubmissionError(undefined) setTestSubmissionResult(undefined) + setShowTestSubmissionResultModal(false) setSelectedTestSubmissionFile(undefined) + clearTestSubmissionPollingTimer() setTesterLoadError(undefined) setSelectedTester(undefined) @@ -1258,9 +1383,11 @@ export const MarathonMatchScorerSection: FC = ( return () => { isMounted = false clearPollingTimer() + clearTestSubmissionPollingTimer() } }, [ clearPollingTimer, + clearTestSubmissionPollingTimer, loadTesterById, challengeId, ]) @@ -1426,6 +1553,73 @@ export const MarathonMatchScorerSection: FC = ( && !isUploadingTestSubmission && !!selectedTestSubmissionFile && testSubmissionPhaseOptions.some(option => option.value === testSubmissionConfigType) + const testSubmissionStatus = normalizeTestSubmissionStatus(testSubmissionResult?.status) + const isTestSubmissionComplete = isTerminalTestSubmissionStatus(testSubmissionResult?.status) + const modalTestSubmissionResult = isTestSubmissionStatusResponse(testSubmissionResult) + ? testSubmissionResult + : undefined + + /** + * Starts polling a validation submission run until scoring reaches a terminal state. + * @param testSubmissionId Validation run identifier returned by upload. + * @returns void + * Used by `handleUploadTestSubmission` after the ECS task is queued. + */ + const startTestSubmissionStatusPolling = useCallback((testSubmissionId: string): void => { + clearTestSubmissionPollingTimer() + + const pollStatus = async (): Promise => { + try { + const statusResponse = await fetchMarathonMatchTestSubmissionStatus( + challengeId, + testSubmissionId, + ) + + if (!isMountedRef.current) { + return + } + + setTestSubmissionResult(statusResponse) + + if (isTerminalTestSubmissionStatus(statusResponse.status)) { + clearTestSubmissionPollingTimer() + setShowTestSubmissionResultModal(true) + + if (normalizeTestSubmissionStatus(statusResponse.status) === 'FAILED') { + showErrorToast('Validation scoring failed') + } else { + showSuccessToast('Validation scoring complete') + } + + return + } + + testSubmissionPollingTimerRef.current = window.setTimeout(() => { + pollStatus() + .catch(() => undefined) + }, POLL_INTERVAL_MS) + } catch (error) { + if (!isMountedRef.current) { + return + } + + const errorMessage = getErrorMessage( + error, + 'Failed to fetch marathon match validation submission status', + ) + + clearTestSubmissionPollingTimer() + setTestSubmissionError(errorMessage) + showErrorToast(errorMessage) + } + } + + pollStatus() + .catch(() => undefined) + }, [ + challengeId, + clearTestSubmissionPollingTimer, + ]) const handleUploadTestSubmission = useCallback( async (): Promise => { @@ -1433,9 +1627,11 @@ export const MarathonMatchScorerSection: FC = ( return } + clearTestSubmissionPollingTimer() setIsUploadingTestSubmission(true) setTestSubmissionError(undefined) setTestSubmissionResult(undefined) + setShowTestSubmissionResultModal(false) try { const uploadResponse = await uploadMarathonMatchTestSubmission( @@ -1447,7 +1643,8 @@ export const MarathonMatchScorerSection: FC = ( ) setTestSubmissionResult(uploadResponse) - showSuccessToast('Validation submission queued for scoring') + showSuccessToast('Validation scoring queued') + startTestSubmissionStatusPolling(uploadResponse.testSubmissionId) } catch (error) { const errorMessage = getErrorMessage( error, @@ -1463,7 +1660,9 @@ export const MarathonMatchScorerSection: FC = ( [ canUploadTestSubmission, challengeId, + clearTestSubmissionPollingTimer, selectedTestSubmissionFile, + startTestSubmissionStatusPolling, testSubmissionConfigType, ], ) @@ -1473,6 +1672,15 @@ export const MarathonMatchScorerSection: FC = ( .catch(() => undefined) }, [handleUploadTestSubmission]) + /** + * Closes the validation submission result modal. + * @returns void + * Used by the modal close button and footer action. + */ + const handleCloseTestSubmissionResultModal = useCallback((): void => { + setShowTestSubmissionResultModal(false) + }, []) + const handleRerunScores = useCallback( async (): Promise => { if (!canRerunScores) { @@ -1728,30 +1936,23 @@ export const MarathonMatchScorerSection: FC = (
    - {testSubmissionResult + {testSubmissionResult && !isTestSubmissionComplete ? (
    - Submission + Validation scoring {' '} - {testSubmissionResult.submissionId} + {testSubmissionStatus.toLowerCase()} - Task + Progress {' '} - {testSubmissionResult.taskId} + {formatTestSubmissionProgress( + isTestSubmissionStatusResponse(testSubmissionResult) + ? testSubmissionResult.progress + : undefined, + )} - {testSubmissionResult.cloudWatchLogsConsoleUrl - ? ( - - CloudWatch logs - - ) - : undefined}
    ) : undefined} @@ -1956,6 +2157,119 @@ export const MarathonMatchScorerSection: FC = ( />
    + {showTestSubmissionResultModal && modalTestSubmissionResult + ? ( + + )} + onClose={handleCloseTestSubmissionResultModal} + open + size='md' + title={ + normalizeTestSubmissionStatus(modalTestSubmissionResult.status) === 'FAILED' + ? 'Validation Scoring Failed' + : 'Validation Scoring Complete' + } + > +
    +
    +
    + Status + {normalizeTestSubmissionStatus(modalTestSubmissionResult.status)} +
    +
    + Score + {formatTestSubmissionScore(modalTestSubmissionResult.score)} +
    +
    + Phase + {modalTestSubmissionResult.configType} +
    +
    + Tests + {formatTestSubmissionTests(modalTestSubmissionResult)} +
    +
    + Progress + {formatTestSubmissionProgress(modalTestSubmissionResult.progress)} +
    +
    + Completed + {formatDateTime(modalTestSubmissionResult.completedAt)} +
    +
    + + {modalTestSubmissionResult.message + ? ( +
    + {modalTestSubmissionResult.message} +
    + ) + : undefined} + +
    + File + {modalTestSubmissionResult.fileName} +
    + + {modalTestSubmissionResult.taskId + ? ( +
    + Task + {modalTestSubmissionResult.taskId} +
    + ) + : undefined} + + {modalTestSubmissionResult.cloudWatchLogsConsoleUrl + ? ( + + Open CloudWatch logs + + ) + : undefined} + + {formatJsonPreview(modalTestSubmissionResult.metadata) + ? ( +
    + Metadata +
    {formatJsonPreview(modalTestSubmissionResult.metadata)}
    +
    + ) + : undefined} + + {formatJsonPreview(modalTestSubmissionResult.currentReview) + ? ( +
    + Current Review +
    {formatJsonPreview(modalTestSubmissionResult.currentReview)}
    +
    + ) + : undefined} + + {formatJsonPreview(modalTestSubmissionResult.impactedReviews) + ? ( +
    + Impacted Reviews +
    {formatJsonPreview(modalTestSubmissionResult.impactedReviews)}
    +
    + ) + : undefined} +
    +
    + ) + : undefined} + {showNewTesterModal ? ( {
    ) : undefined} + {props.showPublicOpportunityValue + ? ( +
    + {String(formMethods.watch('reviewers.0.shouldOpenOpportunity'))} +
    + ) + : undefined} {props.showRoleValue ? (
    @@ -799,6 +807,66 @@ describe('HumanReviewTab', () => { }) }) + it('disables and clears public review opportunity for design manual reviewers', async () => { + mockedUseFetchChallengeTracks.mockReturnValue({ + tracks: [ + { + id: 'track-1', + name: 'Design', + track: 'DESIGN', + }, + ], + }) + mockedFetchDefaultReviewers.mockResolvedValue([ + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'phase-1', + roleId: 'role-1', + scorecardId: 'scorecard-1', + shouldOpenOpportunity: true, + }, + ]) + mockedFetchScorecards.mockResolvedValue([ + { + id: 'scorecard-1', + name: 'Scorecard 1', + phaseId: 'phase-1', + }, + ]) + + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('button', { name: 'Add reviewer' }) as HTMLButtonElement).disabled) + .toBe(false) + }) + + fireEvent.click(screen.getByRole('button', { name: 'Add reviewer' })) + + const checkbox = await screen.findByRole('checkbox', { + name: 'Open public review opportunity', + }) as HTMLInputElement + + expect(checkbox.checked) + .toBe(false) + expect(checkbox.disabled) + .toBe(true) + await waitFor(() => { + expect(screen.getByTestId('public-opportunity-value').textContent) + .toBe('false') + }) + expect(screen.getByTestId('reviewers.0.memberId')) + .not.toBeNull() + }) + it('defaults new manual reviewer cards to regular review type', async () => { mockedFetchScorecards.mockResolvedValue([]) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx index c4bb96680..16222c2a0 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx @@ -762,6 +762,7 @@ function getErrorMessage(error: unknown, fallbackMessage: string): string { } interface PublicOpportunityCheckboxFieldProps { + disabled?: boolean name: string onChange?: (checked: boolean) => void } @@ -775,10 +776,14 @@ const PublicOpportunityCheckboxField: FC = name: props.name, }) const field = controller.field - const checked = field.value === true + const checked = !props.disabled && field.value === true const handleChange = useCallback( (event: ChangeEvent): void => { + if (props.disabled) { + return + } + const nextValue = event.target.checked field.onChange(nextValue) props.onChange?.(nextValue) @@ -798,6 +803,7 @@ const PublicOpportunityCheckboxField: FC = { }, [challengeTracks, normalizedTrackId], ) + const isDesignTrackSelected = selectedScorecardTrack === 'DESIGN' const selectedScorecardType = useMemo( (): string => { if (!normalizedTypeId) { @@ -1228,6 +1235,37 @@ export const HumanReviewTab: FC = () => { reviewerRows, ]) + useEffect(() => { + if (!isDesignTrackSelected) { + return + } + + reviewerRows.forEach((reviewer, reviewerIndex) => { + const fieldIndex = getReviewerFieldIndex(reviewerIndex) + + if ( + fieldIndex === undefined + || !isPublicOpportunityOpen(reviewer) + ) { + return + } + + formContext.setValue( + `reviewers.${fieldIndex}.shouldOpenOpportunity` as any, + false, + { + shouldDirty: false, + shouldValidate: true, + }, + ) + }) + }, [ + formContext, + getReviewerFieldIndex, + isDesignTrackSelected, + reviewerRows, + ]) + useEffect(() => { if (isScorecardsLoading || loadError) { return @@ -1797,6 +1835,9 @@ export const HumanReviewTab: FC = () => { ...reviewerFromDefaults, phaseId, roleId: roleIdForResolvedPhase || reviewerFromDefaults.roleId, + shouldOpenOpportunity: isDesignTrackSelected + ? false + : reviewerFromDefaults.shouldOpenOpportunity, }, ], { shouldDirty: true, @@ -1807,6 +1848,7 @@ export const HumanReviewTab: FC = () => { defaultReviewers, allReviewerRows, formContext, + isDesignTrackSelected, phaseNameById, phases, reviewerRows, @@ -1930,7 +1972,9 @@ export const HumanReviewTab: FC = () => { return undefined } - const shouldOpenOpportunity = isPublicOpportunityOpen(reviewer) + const shouldOpenOpportunity = isDesignTrackSelected + ? false + : isPublicOpportunityOpen(reviewer) const reviewerCount = getReviewerCount(reviewer) const scorecardOptions = getScorecardOptionsForReviewer(reviewer) const reviewerPrefix = `reviewers.${fieldIndex}` @@ -1938,6 +1982,7 @@ export const HumanReviewTab: FC = () => { || reviewer.phaseId || index const reviewerKey = `${reviewerPrefix}-${reviewerIdentity}` + const shouldDisablePublicOpportunity = isDesignTrackSelected return (
    { options={REVIEW_OPPORTUNITY_OPTIONS} /> diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.spec.tsx index 772298c75..f62b3e388 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.spec.tsx @@ -28,6 +28,7 @@ jest.mock('../../../../../lib/hooks', () => ({ const mockedUseFetchTerms = useFetchTerms as jest.MockedFunction const STANDARD_TERM_ID = 'standard-terms-id' +const CONFIGURED_STANDARD_TERM_ID = 'configured-standard-terms-id' const baseDefaultValues: ChallengeEditorFormData = { description: 'Public challenge specification', @@ -103,6 +104,20 @@ describe('TermsField', () => { .toBe(STANDARD_TERM_ID) }) + it('prefers the configured standard term id over legacy matches', () => { + expect(findDefaultStandardTermId([ + { + id: STANDARD_TERM_ID, + title: 'Standard Terms v3', + }, + { + id: CONFIGURED_STANDARD_TERM_ID, + title: 'Updated Standard Terms', + }, + ], CONFIGURED_STANDARD_TERM_ID)) + .toBe(CONFIGURED_STANDARD_TERM_ID) + }) + it('defaults the standard term for new challenges', async () => { render( , diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.tsx index 5688d907e..8272fd877 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/TermsField/TermsField.tsx @@ -12,7 +12,10 @@ import { FormSelectField, FormSelectOption, } from '../../../../../lib/components/form' -import { DEFAULT_NDA_UUID } from '../../../../../lib/constants/challenge-editor.constants' +import { + DEFAULT_NDA_UUID, + DEFAULT_STANDARD_TERMS_UUID, +} from '../../../../../lib/constants/challenge-editor.constants' import { useFetchTerms, UseFetchTermsResult, @@ -44,7 +47,20 @@ function normalizeTermTitle(value: string): string { .toLowerCase() } -export function findDefaultStandardTermId(terms: Term[]): string | undefined { +export function findDefaultStandardTermId( + terms: Term[], + configuredDefaultTermId: string | undefined = DEFAULT_STANDARD_TERMS_UUID, +): string | undefined { + const normalizedConfiguredDefaultTermId = configuredDefaultTermId?.trim() + + if ( + normalizedConfiguredDefaultTermId + && terms.some(term => term.id === normalizedConfiguredDefaultTermId) + ) { + return normalizedConfiguredDefaultTermId + } + + // Keep legacy discovery as a fallback until every environment has an explicit default UUID. return terms.find(term => ( LEGACY_DEFAULT_STANDARD_TERM_IDS.has(term.id) || DEFAULT_STANDARD_TERM_TITLES.has(normalizeTermTitle(term.title)) diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index d29f0f7a8..5b39ca67d 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -3,10 +3,36 @@ import { get } from 'lodash' import { getReactEnv } from './react-env' import type { + ChallengeTypeNamesByTrackConfig, LocalServiceOverride, SSOLoginProviderConfig, } from './global-config.model' +const DEFAULT_WORK_CREATE_CHALLENGE_TYPES_BY_TRACK: ChallengeTypeNamesByTrackConfig = { + DATA_SCIENCE: [ + 'Challenge', + 'First2Finish', + 'Marathon Match', + 'Task', + ], + DESIGN: [ + 'Challenge', + 'First2Finish', + 'Task', + ], + DEVELOP: [ + 'Challenge', + 'First2Finish', + 'Marathon Match', + 'Task', + ], + QA: [ + 'Challenge', + 'First2Finish', + 'Task', + ], +} + function parseSSOLoginProviders( raw: string | undefined, ): SSOLoginProviderConfig[] { @@ -23,6 +49,56 @@ function parseSSOLoginProviders( } } +/** + * Parses the work-app create-challenge type allowlist from a JSON environment value. + * + * @param raw JSON object whose keys are challenge tracks and values are allowed challenge type names. + * @param fallback default allowlist used when the environment value is missing or invalid. + * @returns parsed track-to-type-name config, or `fallback` when parsing cannot produce one. + * @remarks Used by `WORK_CREATE_CHALLENGE_TYPES_BY_TRACK` so deployments can hide active internal + * challenge types without changing work-app code. + * @throws Does not throw. + */ +function parseChallengeTypeNamesByTrack( + raw: string | undefined, + fallback: ChallengeTypeNamesByTrackConfig, +): ChallengeTypeNamesByTrackConfig { + if (!raw) { + return fallback + } + + try { + const parsed = JSON.parse(raw) as unknown + + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { + return fallback + } + + const config = Object.entries(parsed) + .reduce((result, [track, challengeTypes]) => { + if (!Array.isArray(challengeTypes)) { + return result + } + + const normalizedChallengeTypes = challengeTypes + .filter((challengeType): challengeType is string => ( + typeof challengeType === 'string' && challengeType.trim().length > 0 + )) + .map(challengeType => challengeType.trim()) + + if (track.trim().length > 0 && normalizedChallengeTypes.length > 0) { + result[track.trim()] = normalizedChallengeTypes + } + + return result + }, {}) + + return Object.keys(config).length > 0 ? config : fallback + } catch (error) { + return fallback + } +} + export const ENV = getReactEnv<'prod' | 'dev' | 'qa' | 'local'>( 'HOST_ENV', 'dev', @@ -51,6 +127,14 @@ export const CHALLENGE_API_VERSION: string = getReactEnv( 'CHALLENGE_API_VERSION', 'v5', ) +export const WORK_CREATE_CHALLENGE_TYPES_BY_TRACK: ChallengeTypeNamesByTrackConfig + = parseChallengeTypeNamesByTrack( + getReactEnv( + 'WORK_CREATE_CHALLENGE_TYPES_BY_TRACK', + undefined, + ), + DEFAULT_WORK_CREATE_CHALLENGE_TYPES_BY_TRACK, + ) export const COMMUNITY_APP_URL: string = getReactEnv( 'COMMUNITY_APP_URL', TOPCODER_URL, @@ -138,6 +222,9 @@ export const TERMS_URL = 'https://www.topcoder-dev.com/challenges/terms/detail/317cd8f9-d66c-4f2a-8774-63c612d99cd4' export const NDA_TERMS_URL = 'https://www.topcoder-dev.com/challenges/terms/detail/e5811a7b-43d1-407a-a064-69e5015b4900' +export const DEFAULT_STANDARD_TERMS_UUID = '0a507fb7-3fe0-402b-b121-1a24af4a9cf1' +export const NDA_DOCUSIGN_TEMPLATE_ID = '400b989d-1c75-4889-b6f6-421e1f924709' + export const PRIVACY_POLICY_URL = `${TOPCODER_URL}/policy` export const GAMIFICATION_ORG_ID = getReactEnv( diff --git a/src/config/environments/global-config.model.ts b/src/config/environments/global-config.model.ts index 0f2706c12..98ed89c06 100644 --- a/src/config/environments/global-config.model.ts +++ b/src/config/environments/global-config.model.ts @@ -9,6 +9,8 @@ export interface LocalServiceOverride { target: string } +export type ChallengeTypeNamesByTrackConfig = Record + export interface GlobalConfig { TC_DOMAIN: string TOPCODER_URL: string @@ -17,6 +19,7 @@ export interface GlobalConfig { USER_PROFILE_URL: string CHALLENGE_API_URL?: string CHALLENGE_API_VERSION?: string + WORK_CREATE_CHALLENGE_TYPES_BY_TRACK: ChallengeTypeNamesByTrackConfig COMMUNITY_APP_URL?: string REVIEW_APP_URL?: string DIRECT_PROJECT_URL?: string @@ -56,6 +59,8 @@ export interface GlobalConfig { } TERMS_URL?: string NDA_TERMS_URL?: string + DEFAULT_STANDARD_TERMS_UUID?: string + NDA_DOCUSIGN_TEMPLATE_ID?: string MEMBER_VERIFY_LOOKER: number ENABLE_TCA_CERT_MONETIZATION: boolean VANILLA_FORUM: { diff --git a/src/config/environments/prod.env.ts b/src/config/environments/prod.env.ts index a334890d9..5127275df 100644 --- a/src/config/environments/prod.env.ts +++ b/src/config/environments/prod.env.ts @@ -5,6 +5,10 @@ export * from './default.env' export const TERMS_URL = 'https://www.topcoder.com/challenges/terms/detail/564a981e-6840-4a5c-894e-d5ad22e9cd6f' export const NDA_TERMS_URL = 'https://www.topcoder.com/challenges/terms/detail/c41e90e5-4d0e-4811-bd09-38ff72674490' +export const NDA_DOCUSIGN_TEMPLATE_ID = getReactEnv( + 'NDA_DOCUSIGN_TEMPLATE_ID', + '8b101e82-87c0-42c9-8440-d922749c4076', +) export const VANILLA_FORUM = { V2_URL: 'https://vanilla.topcoder.com/api/v2', diff --git a/src/config/environments/react-env.spec.ts b/src/config/environments/react-env.spec.ts new file mode 100644 index 000000000..fcf4562cc --- /dev/null +++ b/src/config/environments/react-env.spec.ts @@ -0,0 +1,33 @@ +import { getReactEnv } from './react-env' + +const originalEnv = process.env + +describe('getReactEnv', () => { + beforeEach(() => { + process.env = { ...originalEnv } + delete process.env.REACT_APP_FILESTACK_REGION + delete process.env.REACT_APP_FEATURE_FLAG + delete process.env.REACT_APP_RETRY_COUNT + }) + + afterAll(() => { + process.env = originalEnv + }) + + it('uses the provided default when an env value is blank', () => { + process.env.REACT_APP_FILESTACK_REGION = '' + + expect(getReactEnv('FILESTACK_REGION', 'us-east-1')) + .toBe('us-east-1') + }) + + it('keeps parsing boolean and numeric env values', () => { + process.env.REACT_APP_FEATURE_FLAG = 'true' + process.env.REACT_APP_RETRY_COUNT = '2' + + expect(getReactEnv('FEATURE_FLAG', false)) + .toBe(true) + expect(getReactEnv('RETRY_COUNT', 0)) + .toBe(2) + }) +}) diff --git a/src/config/environments/react-env.ts b/src/config/environments/react-env.ts index 1ed71d508..d46207e73 100644 --- a/src/config/environments/react-env.ts +++ b/src/config/environments/react-env.ts @@ -4,14 +4,20 @@ export function getReactEnv(varName: string, defaultValue?: string | boolean | number): T { const hasDefaultValue: boolean = arguments.length > 1 - let value = process.env[`REACT_APP_${varName}`] as unknown as T + const rawValue = process.env[`REACT_APP_${varName}`] - if (value === undefined && !hasDefaultValue) { + if (rawValue === undefined && !hasDefaultValue) { throw new Error(`${varName} is not defined in process.env!`) } + if (rawValue?.trim() === '' && hasDefaultValue) { + return defaultValue as T + } + + let value = rawValue as unknown as T + // convert to number - if (!Number.isNaN(Number(value))) { + if (rawValue?.trim() !== '' && !Number.isNaN(Number(value))) { value = Number(value as unknown) as T } diff --git a/src/libs/core/lib/profile/user-profile.model.ts b/src/libs/core/lib/profile/user-profile.model.ts index c37611ee8..3f2df25ff 100644 --- a/src/libs/core/lib/profile/user-profile.model.ts +++ b/src/libs/core/lib/profile/user-profile.model.ts @@ -1,6 +1,6 @@ import { UserSkill } from './user-skill.model' -export type TC_TRACKS = 'DEVELOP' | 'DESIGN' | 'DATA_SCIENCE' +export type TC_TRACKS = 'DEVELOP' | 'DESIGN' | 'DATA_SCIENCE' | 'QA' export enum NamesAndHandleAppearance { both = 'namesAndHandle', @@ -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..6ea87797d 100644 --- a/src/libs/core/lib/profile/user-stats.model.ts +++ b/src/libs/core/lib/profile/user-stats.model.ts @@ -57,10 +57,48 @@ 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 = { + Challenge?: MemberStats + 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,29 +117,31 @@ export type UserStats = { projects: number reposts: number } - DATA_SCIENCE?: { - MARATHON_MATCH: MemberStats - SRM: SRMStats + DATA_SCIENCE?: DataScienceStats + DEVELOP?: { challenges: number mostRecentEventDate: number - mostRecentEventName: string mostRecentSubmission: number + subTracks: Array wins: number } - DEVELOP?: { + DESIGN?: { challenges: number mostRecentEventDate: number mostRecentSubmission: number subTracks: Array wins: number } - DESIGN?: { + QA?: { challenges: number mostRecentEventDate: number mostRecentSubmission: number subTracks: Array wins: number } + AI?: MemberStatsGroup + AI_ENGINEER?: MemberStatsGroup + AI_ENGINEERING?: MemberStatsGroup } export type StatsHistory = { @@ -127,6 +167,9 @@ export type UserStatsHistory = { }> } DATA_SCIENCE?: { + Challenge?: { + history: Array + }, SRM?: { history: Array }, @@ -140,4 +183,10 @@ export type UserStatsHistory = { history: Array }> } + QA?: { + subTracks: Array<{ + name: string + history: Array + }> + } } 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} - /> )}
    diff --git a/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss b/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss index 201347bb3..3e2b3d54e 100644 --- a/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss +++ b/src/libs/shared/lib/components/profile-picture/ProfilePicture.module.scss @@ -7,6 +7,7 @@ width: 100%; aspect-ratio: 1 / 1; border-radius: 50%; + clip-path: circle(50%); overflow: hidden; display: flex;