diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx index 9c2396887..f0239de1a 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.spec.tsx @@ -1,4 +1,4 @@ -import type { MemberStats, UserStats } from '~/libs/core' +import type { MemberStats, UserStats, UserStatsHistory } from '~/libs/core' import { getActiveTracks, @@ -10,6 +10,7 @@ import { jest.mock('~/libs/core', () => ({ useMemberStats: jest.fn(), + useStatsHistory: jest.fn(), }), { virtual: true, }) @@ -331,6 +332,107 @@ describe('getActiveTracks', () => { .not.toContain('AI Engineering') }) + it('deduplicates Development totals when AI Engineering and Challenge share challenge history', () => { + const sharedChallengeHistory = [ + { + challengeId: 'sales-app-dev-ai-expo', + challengeName: 'Sales App dev AI expo', + newRating: 1200, + placement: 1, + ratingDate: 1781237773026, + }, + { + challengeId: 'sales-app-dev-ai', + challengeName: 'Sales App dev AI', + newRating: 1200, + placement: 1, + ratingDate: 1781237773027, + }, + ] + const statsHistory = { + DATA_SCIENCE: { + 'AI Engineering': { + history: [ + { + challengeId: 'dev-mm-with-ai', + challengeName: 'Dev MM with AI', + newRating: 1200, + placement: 1, + ratingDate: 1781237773021, + }, + { + challengeId: 'ds-mm-with-ai-exponential-league', + challengeName: 'DS MM with AI Exponential League', + newRating: 1200, + placement: 1, + ratingDate: 1781237773022, + }, + { + challengeId: 'ds-with-ai-exponential-league', + challengeName: 'DS with AI Exponential League', + newRating: 1200, + placement: 1, + ratingDate: 1781237773023, + }, + { + challengeId: 'sales-app-ds-ai', + challengeName: 'Sales App DS AI', + newRating: 1200, + placement: 1, + ratingDate: 1781237773024, + }, + ...sharedChallengeHistory, + ], + }, + }, + DEVELOP: { + subTracks: [ + { + history: sharedChallengeHistory, + name: 'Challenge', + }, + ], + }, + } as unknown as UserStatsHistory + const activeTracks: MemberStatsTrack[] = getActiveTracks({ + DATA_SCIENCE: { + 'AI Engineering': { + challenges: 6, + rank: { + rating: 1200, + }, + submissions: { + submissions: 6, + }, + wins: 6, + }, + }, + DEVELOP: { + subTracks: [ + { + challenges: 2, + name: 'Challenge', + submissions: { + submissions: 2, + }, + wins: 2, + }, + ], + }, + } as unknown as UserStats, statsHistory) + const developmentTrack: MemberStatsTrack | undefined = activeTracks + .find(track => track.name === 'Development') + + expect(developmentTrack) + .toEqual(expect.objectContaining({ + challenges: 6, + submissions: 6, + wins: 6, + })) + expect(developmentTrack?.subTracks.map(track => track.name)) + .toEqual(['Challenge', 'AI Engineering']) + }) + it('keeps rated custom non-AI data science paths visible as member stats tracks', () => { const activeTracks: MemberStatsTrack[] = getActiveTracks({ challenges: 5, diff --git a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx index fa8f5e2f7..efeddb2c6 100644 --- a/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx +++ b/src/apps/profiles/src/hooks/useFetchActiveTracks.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { filter, find, get, orderBy } from 'lodash' +import { filter, find, get, orderBy, sumBy } from 'lodash' import { DataScienceRatingPathStats, @@ -9,10 +9,14 @@ import { StatsHistory, useMemberStats, UserStats, + UserStatsHistory, + useStatsHistory, } from '~/libs/core' import { calcProportionalAverage } from '../lib/math.utils' +import { getTrackHistoryFromStats } from './useTrackHistory' + const testingSubTrackNames = new Set([ 'BUG_HUNT', 'TEST_SCENARIOS', @@ -63,6 +67,10 @@ export interface SubTrackSummaryStats { wins: number } +export interface TrackSummaryStats extends SubTrackSummaryStats { + challenges: number +} + const getFiniteNumber = (value: unknown): number | undefined => ( typeof value === 'number' && Number.isFinite(value) ? value : undefined ) @@ -162,6 +170,113 @@ const isTestingSubTrack = (subTrack?: MemberStats): boolean => ( !!subTrack?.name && testingSubTrackNames.has(subTrack.name) ) +const getSubTrackDisplayChallengeCount = (subTrack?: MemberStats): number => ( + getFiniteNumber(subTrack?.challenges) ?? 0 +) + +const getHistoryChallengeKey = (history: StatsHistory): string => [ + history.challengeId, + history.challengeName, + history.ratingDate ?? history.date, +].map(value => String(value ?? '')) + .join('::') + +interface SubTrackHistorySummary { + history: StatsHistory[] + stats: SubTrackSummaryStats + subTrack: MemberStats +} + +const getSubTrackHistorySummaries = ( + subTracks: MemberStats[], + statsHistory?: UserStatsHistory, +): SubTrackHistorySummary[] => ( + subTracks.map(subTrack => { + const history = statsHistory ? getTrackHistoryFromStats(statsHistory, subTrack) : [] + + return { + history, + stats: getSubTrackSummaryStats(subTrack, history), + subTrack, + } + }) +) + +const getFallbackTrackSummaryStats = (summaries: SubTrackHistorySummary[]): TrackSummaryStats => ({ + challenges: sumBy(summaries, summary => getSubTrackDisplayChallengeCount(summary.subTrack)), + submissions: sumBy(summaries, summary => summary.stats.submissions), + wins: sumBy(summaries, summary => summary.stats.wins), +}) + +/** + * Builds parent track totals from subtracks, de-duplicating by challenge history when available. + * + * Development can show the same AI challenge under both `Challenge` and + * `AI Engineering`. When history rows are available, duplicate challenge ids are + * counted once for the parent totals while each child card keeps its own stats. + * + * @param {MemberStats[]} subTracks - Active subtracks included in the parent track. + * @param {UserStatsHistory | undefined} statsHistory - Optional stats-history payload for the same member. + * @returns {TrackSummaryStats} Parent challenge, win, and submission totals for display. + */ +export const getTrackSummaryStats = ( + subTracks: MemberStats[], + statsHistory?: UserStatsHistory, +): TrackSummaryStats => { + const summaries = getSubTrackHistorySummaries(subTracks, statsHistory) + + if (!statsHistory) { + return getFallbackTrackSummaryStats(summaries) + } + + const historySummaries = summaries.filter(summary => summary.history.length > 0) + + if (historySummaries.length === 0) { + return getFallbackTrackSummaryStats(summaries) + } + + const uniqueHistoryByChallenge = new Map() + let hasDuplicateHistory = false + + historySummaries.forEach(summary => { + summary.history.forEach(history => { + const key = getHistoryChallengeKey(history) + const existingHistory = uniqueHistoryByChallenge.get(key) + + if (existingHistory) { + hasDuplicateHistory = true + } + + if (!existingHistory || existingHistory.placement !== 1) { + uniqueHistoryByChallenge.set(key, history) + } + }) + }) + + const uniqueHistory = Array.from(uniqueHistoryByChallenge.values()) + const noHistorySummaryStats = getFallbackTrackSummaryStats( + summaries.filter(summary => summary.history.length === 0), + ) + const historyChallengeExtras = sumBy(historySummaries, summary => Math.max( + 0, + getSubTrackDisplayChallengeCount(summary.subTrack) - summary.history.length, + )) + const historySubmissionExtras = sumBy(historySummaries, summary => Math.max( + 0, + summary.stats.submissions - summary.history.length, + )) + const uniqueHistoryWins = uniqueHistory.filter(history => history.placement === 1).length + const historyStatsWins = hasDuplicateHistory + ? Math.max(...historySummaries.map(summary => summary.stats.wins)) + : sumBy(historySummaries, summary => summary.stats.wins) + + return { + challenges: uniqueHistory.length + historyChallengeExtras + noHistorySummaryStats.challenges, + submissions: uniqueHistory.length + historySubmissionExtras + noHistorySummaryStats.submissions, + wins: (uniqueHistoryWins > 0 ? uniqueHistoryWins : historyStatsWins) + noHistorySummaryStats.wins, + } +} + /** * Pick the Data Science subtrack rating used by the summary card. * @@ -279,27 +394,27 @@ export const getMemberChallengePoints = (memberStats?: UserStats): number | unde * * @param {string} trackName - The name of the track. * @param {MemberStats[]} subTracks - List of subtracks within the main track. + * @param {UserStatsHistory | undefined} statsHistory - Optional history used to de-duplicate parent totals. * @returns {MemberStatsTrack} - Aggregated data for the track. */ -const buildTrackData = (trackName: string, allSubTracks: MemberStats[]): MemberStatsTrack => { +const buildTrackData = ( + trackName: string, + allSubTracks: MemberStats[], + statsHistory?: UserStatsHistory, +): MemberStatsTrack => { const subTracks = allSubTracks.filter(isActiveSubTrack) - // Calculate total wins, challenges, and submissions for the track - const totalWins = subTracks.reduce((sum, subTrack) => (sum + (subTrack?.wins || 0)), 0) - const challengesCount = subTracks.reduce((sum, subTrack) => (sum + (subTrack?.challenges || 0)), 0) - const submissionsCount = subTracks.reduce((sum, subTrack) => ( - sum + (getSubTrackDisplaySubmissionCount(subTrack) ?? 0) - ), 0) + const summaryStats = getTrackSummaryStats(subTracks, statsHistory) const hasSubmissionCounts = subTracks.some(subTrack => getSubTrackDisplaySubmissionCount(subTrack) !== undefined) // Return aggregated track data return { - challenges: challengesCount, + challenges: summaryStats.challenges, isActive: subTracks.length > 0, name: trackName, order: 1, - submissions: hasSubmissionCounts ? submissionsCount : undefined, + submissions: hasSubmissionCounts ? summaryStats.submissions : undefined, subTracks, - wins: totalWins, + wins: summaryStats.wins, } } @@ -574,7 +689,10 @@ const getDataScienceRatingPathTrackData = (memberStats?: UserStats): MemberStats * @param {UserStats | undefined} memberStats - The raw stats payload for the user. * @returns {MemberStatsTrack[]} - List of active tracks for the user. */ -export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => { +export const getActiveTracks = ( + memberStats?: UserStats, + statsHistory?: UserStatsHistory, +): MemberStatsTrack[] => { // Create mappings for data science subtracks const dataScienceSubTracks: {[key: string]: MemberStats | SRMStats} = { // Map Challenge subtrack @@ -641,6 +759,7 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => : [aiEngineeringDevelopmentSubTrack]), ] .filter(subTrack => !isTestingSubTrack(subTrack)), + statsHistory, ) ) @@ -710,8 +829,9 @@ export const getActiveTracks = (memberStats?: UserStats): MemberStatsTrack[] => */ export const useFetchActiveTracks = (userHandle: string): MemberStatsTrack[] => { const memberStats: UserStats | undefined = useMemberStats(userHandle) + const statsHistory: UserStatsHistory | undefined = useStatsHistory(userHandle) - return useMemo(() => getActiveTracks(memberStats), [memberStats]) + return useMemo(() => getActiveTracks(memberStats, statsHistory), [memberStats, statsHistory]) } /** diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx index 0e0269bfc..0eb804fe6 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.spec.tsx @@ -181,6 +181,31 @@ describe('MemberRatingCard', () => { .toBeInTheDocument() }) + it('shows top one percent when the member rating is above the highest distribution range', () => { + mockedUseMemberStats.mockReturnValue({ + DATA_SCIENCE: { + MARATHON_MATCH: { + mostRecentEventDate: 1000, + rank: { + rating: 4051, + }, + }, + }, + maxRating: { + rating: 4051, + }, + } as unknown as UserStats) + + render() + + expect(screen.getByText('4051')) + .toBeInTheDocument() + expect(screen.getByText('Top 1%')) + .toBeInTheDocument() + expect(screen.getByText('Data Scientists')) + .toBeInTheDocument() + }) + it('disables the percentile tooltip while the rating modal is open', () => { mockedUseMemberStats.mockReturnValue({ DATA_SCIENCE: { diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts index abeda1c63..a99df12cf 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.spec.ts @@ -123,6 +123,11 @@ describe('calculateTopPercentileFromDistribution', () => { .toBeCloseTo(55) }) + it('returns the top known member percentage when the rating is above the highest range', () => { + expect(calculateTopPercentileFromDistribution(distribution, 4051)) + .toBeCloseTo(0.1) + }) + it('returns undefined when the rating or distribution cannot be used', () => { expect(calculateTopPercentileFromDistribution(distribution, undefined)) .toBeUndefined() diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts index 3b028d28b..772390b6b 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.utils.ts @@ -214,7 +214,9 @@ const getDistributionRanges = ( * * 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. + * counts the proportional share at or above the member rating. Ratings above + * the final bucket are treated as the top known member so the card still shows + * the best visible percentile instead of hiding the badge. * * @param {UserStatsDistributionResponse['distribution'] | undefined} distribution - Raw rating distribution buckets. * @param {number | undefined} memberRating - The visible member rating in the same track/subtrack. @@ -253,6 +255,10 @@ export const calculateTopPercentileFromDistribution = ( return total + (range.value * (ratingAndAboveSize / rangeSize)) }, 0) + if (membersAtOrAboveRating <= 0 && rating > ranges[ranges.length - 1].end) { + return 100 / totalMembers + } + if (membersAtOrAboveRating <= 0) { return undefined } diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss index 2c27fc0ec..4635899c4 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss @@ -206,7 +206,7 @@ z-index: 2; &::after { - background: #F2C900; + background: currentColor; bottom: 0; content: ''; display: block; diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx index bb0206a0e..b703659fa 100644 --- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx +++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.spec.tsx @@ -4,6 +4,7 @@ import type { PropsWithChildren } from 'react' import { render, screen, within } from '@testing-library/react' import type { UserProfile } from '~/libs/core' +import { getRatingColor } from '~/libs/core' import MemberRatingInfoModal from './MemberRatingInfoModal' @@ -53,7 +54,7 @@ const expandedTailRatingDistribution = { } jest.mock('~/libs/core', () => ({ - getRatingColor: jest.fn(() => '#616BD5'), + getRatingColor: jest.fn(), }), { virtual: true, }) @@ -79,7 +80,13 @@ jest.mock('../../../../lib', () => ({ .toFixed(digits), })) +const mockedGetRatingColor = getRatingColor as jest.MockedFunction + describe('MemberRatingInfoModal', () => { + beforeEach(() => { + mockedGetRatingColor.mockReturnValue('#616BD5') + }) + it('renders the position summary without the pyramid graphic', () => { render( { const marker = screen.getByTestId('rating-member-marker') + expect(marker) + .toHaveStyle('color: #616BD5') expect(parseFloat(marker.style.left)) .toBeLessThan(80) expect(marker) 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 7d9e6341c..7e06eb160 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 @@ -265,6 +265,7 @@ const MemberRatingInfoModal: FC = (props: MemberRati const displayName: string = getMemberDisplayName(props.profile) const titleDisplayName: string = displayName .toUpperCase() + const ratingColor: string = getRatingColor(props.rating) const selectedRatingTier: RatingTier = getRatingTier(props.rating) const distributionRanges: RatingDistributionRange[] = useMemo(() => ( getDistributionRanges(props.ratingDistribution?.distribution) @@ -311,7 +312,7 @@ const MemberRatingInfoModal: FC = (props: MemberRati
Overall Rating - + {props.rating ?? '--'} {getRatingTierName(props.rating)} @@ -370,7 +371,10 @@ const MemberRatingInfoModal: FC = (props: MemberRati shouldStackMarkerRating && styles.memberMarkerStacked, )} data-testid='rating-member-marker' - style={{ left: `${markerPosition}%` }} + style={{ + color: ratingColor, + left: `${markerPosition}%`, + }} >
@@ -382,10 +386,7 @@ const MemberRatingInfoModal: FC = (props: MemberRati )} - + {props.rating}
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 d0819bffd..dc2c0147a 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 @@ -7,6 +7,7 @@ import { MemberStats, UserProfile, UserStatsHistory, useStatsHistory } from '~/l import { getSubTrackSummaryStats, getTrackHistoryFromStats, + getTrackSummaryStats, SubTrackSummaryStats, useFetchTrackData, } from '../../../hooks' @@ -48,11 +49,24 @@ const TrackView: FC = props => { ], ['desc', 'desc', 'desc'], ), [subTrackStats]) - const displayTrackData = useMemo(() => (trackData ? { - ...trackData, - submissions: sumBy(subTrackStats, subTrack => subTrack[1].submissions), - wins: sumBy(subTrackStats, subTrack => subTrack[1].wins), - } : undefined), [subTrackStats, trackData]) + const displayTrackData = useMemo(() => { + if (!trackData) { + return undefined + } + + if (trackData.name === 'Development') { + return { + ...trackData, + ...getTrackSummaryStats(trackData.subTracks, statsHistory), + } + } + + return { + ...trackData, + submissions: sumBy(subTrackStats, subTrack => subTrack[1].submissions), + wins: sumBy(subTrackStats, subTrack => subTrack[1].wins), + } + }, [statsHistory, subTrackStats, trackData]) return !displayTrackData ? props.renderDefault() : (
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 8f54de7ef..67c5d603a 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx @@ -9,6 +9,7 @@ import { useFetchEngagements } from '../../hooks/useFetchEngagements' import type { Challenge } from '../../models' import type { BillingAccountDetails } from '../../services' import { fetchChallenge } from '../../services/challenges.service' +import { fetchAssignmentPaymentSplits } from '../../services/payments.service' import BillingAccountLineItemsModal from './BillingAccountLineItemsModal' @@ -24,6 +25,10 @@ jest.mock('../../services/challenges.service', () => ({ fetchChallenge: jest.fn(), })) +jest.mock('../../services/payments.service', () => ({ + fetchAssignmentPaymentSplits: jest.fn(), +})) + jest.mock('~/config', () => ({ EnvironmentConfig: { API: { @@ -64,6 +69,9 @@ jest.mock('~/libs/ui', () => ({ const mockedUseFetchEngagements = useFetchEngagements as jest.MockedFunction const mockedFetchChallenge = fetchChallenge as jest.MockedFunction +const mockedFetchAssignmentPaymentSplits = fetchAssignmentPaymentSplits as jest.MockedFunction< + typeof fetchAssignmentPaymentSplits +> let challengeMarkupById: Map const baseBillingAccountDetails: BillingAccountDetails = { @@ -104,6 +112,8 @@ describe('BillingAccountLineItemsModal', () => { name: `Challenge ${challengeId}`, status: 'ACTIVE', })) + mockedFetchAssignmentPaymentSplits.mockReset() + mockedFetchAssignmentPaymentSplits.mockResolvedValue([]) mockedUseFetchEngagements.mockReset() mockedUseFetchEngagements.mockReturnValue({ engagements: [], @@ -406,6 +416,47 @@ describe('BillingAccountLineItemsModal', () => { .toBeNull() }) + it('uses finance engagement payment splits before API-derived member-payment fallbacks', async () => { + mockedFetchAssignmentPaymentSplits.mockResolvedValue([ + { + billingAccountId: '80001063', + challengeFee: '420.66', + paymentAmount: '342.00', + paymentId: 'd2223b35-10fc-410e-b3f5-6d6ac482caef', + }, + ]) + + renderModal({ + ...baseBillingAccountDetails, + consumedAmounts: [ + { + amount: '762.66', + date: '2026-06-02T13:10:48.235Z', + externalId: 'assignment-5245', + externalName: 'Eng BA', + externalType: 'ENGAGEMENT', + memberPaymentAmount: '753.42', + }, + ], + consumedBudget: 762.66, + markup: 0.01226408, + totalBudgetRemaining: 237.34, + }) + + await waitFor(() => { + expect(screen.getByText('$342.00')) + .toBeTruthy() + expect(screen.getByText('$420.66')) + .toBeTruthy() + }) + expect(screen.queryByText('$753.42')) + .toBeNull() + expect(screen.queryByText('$9.24')) + .toBeNull() + expect(mockedFetchAssignmentPaymentSplits) + .toHaveBeenCalledWith('assignment-5245') + }) + 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 f35b291d2..df2a01675 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -16,6 +16,7 @@ import { import { rootRoute } from '../../../config/routes.config' import { useFetchEngagements } from '../../hooks/useFetchEngagements' import type { + AssignmentPayment, Challenge, Engagement, } from '../../models' @@ -25,7 +26,12 @@ import { combineBillingAccountLineItems, } from '../../services/billing-accounts.service' import { fetchChallenge } from '../../services/challenges.service' -import { calculatePaymentChallengeFee } from '../../utils/payment.utils' +import { fetchAssignmentPaymentSplits } from '../../services/payments.service' +import { + calculatePaymentChallengeFee, + getPaymentAmount, + getPaymentChallengeFee, +} from '../../utils/payment.utils' import { calculateMemberPaymentAmount, getCopilotMemberPaymentsBudgetInfo, @@ -36,17 +42,25 @@ import styles from './BillingAccountLineItemsModal.module.scss' type SortField = 'amount' | 'status' | 'date' type SortOrder = 'asc' | 'desc' type ChallengeDetailsById = Map +type AssignmentPaymentsById = Map interface BillingAccountModalLineItem extends BillingAccountLineItem { challengeFeeAmount?: number displayAmount?: number } +interface EngagementPaymentSplit { + challengeFee?: number + paymentAmount: number +} + const ENGAGEMENT_ASSIGNMENT_FILTERS = { includePrivate: true, } const EMPTY_CHALLENGE_DETAILS_BY_ID: ChallengeDetailsById = new Map() +const EMPTY_ASSIGNMENT_PAYMENTS_BY_ID: AssignmentPaymentsById = new Map() +const CURRENCY_AMOUNT_TOLERANCE = 0.01 const EXTERNAL_TYPE_LABELS: Record = { CHALLENGE: 'Challenge', @@ -155,6 +169,125 @@ function normalizeRouteId(value: unknown): string | undefined { return normalizedValue || undefined } +/** + * Rounds payment split values to cents for display and row matching. + * + * @param amount Raw amount from billing or finance data. + * @returns Currency amount rounded to two decimal places. + * @remarks Billing-account rows are stored at ledger precision, while the + * modal displays cents and only needs cent-level comparisons. + */ +function roundCurrencyAmount(amount: number): number { + return Number(amount.toFixed(2)) +} + +/** + * Compares two currency amounts at display precision. + * + * @param firstAmount First amount to compare. + * @param secondAmount Second amount to compare. + * @returns `true` when the amounts match within one cent. + */ +function currencyAmountsMatch(firstAmount: number, secondAmount: number): boolean { + return Math.abs(roundCurrencyAmount(firstAmount) - roundCurrencyAmount(secondAmount)) + <= CURRENCY_AMOUNT_TOLERANCE +} + +/** + * Resolves billing-account ids carried by one finance payment. + * + * @param payment Finance payment row. + * @returns Unique billing-account ids from top-level and detail fields. + */ +function getPaymentBillingAccountIds(payment: AssignmentPayment): string[] { + return Array.from(new Set([ + normalizeRouteId(payment.billingAccountId), + ...(payment.details || []).map(detail => normalizeRouteId(detail.billingAccount)), + ].filter((billingAccountId): billingAccountId is string => !!billingAccountId))) +} + +/** + * Filters assignment payments to the billing account currently being displayed. + * + * @param payments Payments returned for one engagement assignment. + * @param billingAccountId Billing account id from the detail modal. + * @returns Payments with a matching billing-account id, or all payments when + * finance did not expose any billing-account id fields. + */ +function filterPaymentsForBillingAccount( + payments: AssignmentPayment[], + billingAccountId: unknown, +): AssignmentPayment[] { + const normalizedBillingAccountId = normalizeRouteId(billingAccountId) + + if (!normalizedBillingAccountId) { + return payments + } + + const paymentsWithBillingAccount = payments.filter(payment => ( + getPaymentBillingAccountIds(payment).length > 0 + )) + + if (paymentsWithBillingAccount.length === 0) { + return payments + } + + return paymentsWithBillingAccount.filter(payment => ( + getPaymentBillingAccountIds(payment) + .includes(normalizedBillingAccountId) + )) +} + +/** + * Converts a finance payment into the member-payment and fee values used by the modal. + * + * @param payment Finance payment row. + * @returns Payment split when a member-payment amount is available. + */ +function getFinancePaymentSplit(payment: AssignmentPayment): EngagementPaymentSplit | undefined { + const paymentAmount = getPaymentAmount(payment) + + if (paymentAmount === undefined) { + return undefined + } + + const challengeFee = getPaymentChallengeFee(payment) + + return { + challengeFee: challengeFee === undefined + ? undefined + : roundCurrencyAmount(challengeFee), + paymentAmount: roundCurrencyAmount(paymentAmount), + } +} + +/** + * Adds finance payment splits together for aggregate billing-account rows. + * + * @param splits Payment splits from finance for one assignment. + * @returns Aggregate split, or `undefined` when no split exists. + */ +function getAggregatePaymentSplit( + splits: EngagementPaymentSplit[], +): EngagementPaymentSplit | undefined { + if (splits.length === 0) { + return undefined + } + + return { + challengeFee: splits.some(split => split.challengeFee === undefined) + ? undefined + : roundCurrencyAmount(splits.reduce( + (total, split) => total + (split.challengeFee || 0), + 0, + )), + paymentAmount: roundCurrencyAmount(splits.reduce( + (total, split) => total + split.paymentAmount, + 0, + )), + } +} + /** * Builds an absolute work-app path with the configured root route prefix. * @@ -240,6 +373,28 @@ function getChallengeLineItemIds(items: BillingAccountLineItem[]): string[] { )) } +/** + * Collects consumed engagement assignment ids that may need finance split hydration. + * + * @param items Normalized billing-account line items. + * @returns Unique assignment ids from engagement consumed rows. + * @remarks Locked engagement rows do not correspond to completed finance + * payments, and rows that already carry the persisted split do not need + * additional finance lookups. + */ +function getEngagementPaymentAssignmentIds(items: BillingAccountLineItem[]): string[] { + return Array.from(new Set( + items + .filter(item => ( + item.externalType === 'ENGAGEMENT' + && item.status === 'consumed' + && (item.paymentAmount === undefined || item.challengeFee === undefined) + )) + .map(item => normalizeRouteId(item.externalId)) + .filter((id): id is string => !!id), + )) +} + /** * Fetches challenge details for billing-account rows without failing the whole modal. * @@ -266,6 +421,30 @@ async function fetchChallengeDetailsById( ) } +/** + * Fetches raw finance payments for engagement assignment ids. + * + * @param assignmentIds Engagement assignment ids referenced by consumed line items. + * @returns Map keyed by assignment id with finance payment rows. + * @remarks Individual assignment failures are ignored so billing-account + * details still render with existing fallback values. + */ +async function fetchAssignmentPaymentsById( + assignmentIds: string[], +): Promise { + const entries = await Promise.all(assignmentIds.map(async assignmentId => { + try { + const payments = await fetchAssignmentPaymentSplits(assignmentId) + + return [assignmentId, payments] as const + } catch { + return [assignmentId, [] as AssignmentPayment[]] as const + } + })) + + return new Map(entries) +} + /** * Resolves the billing markup that applies to a row's challenge fee. * @@ -364,11 +543,91 @@ function getConsumedChallengeFeeAmount( : undefined } +/** + * Resolves an exact finance split for one consumed engagement line item. + * + * @param item Billing-account engagement row. + * @param billingAccountDetails Parent billing account details. + * @param assignmentPaymentsById Finance payments keyed by assignment id. + * @returns Exact payment split when finance payments can be matched to the row. + * @remarks Some billing-account rows store only the combined ledger charge. + * Finance keeps the original member payment and fee, so those values are used + * before trusting API-provided markup-derived member-payment fallbacks. + */ +function getEngagementFinancePaymentSplit( + item: BillingAccountLineItem, + billingAccountDetails: BillingAccountDetails, + assignmentPaymentsById: AssignmentPaymentsById | undefined, +): EngagementPaymentSplit | undefined { + if (item.externalType !== 'ENGAGEMENT' || item.status !== 'consumed') { + return undefined + } + + const assignmentId = normalizeRouteId(item.externalId) + const assignmentPayments = assignmentId + ? assignmentPaymentsById?.get(assignmentId) + : undefined + + if (!assignmentPayments?.length) { + return undefined + } + + const financeSplits = filterPaymentsForBillingAccount( + assignmentPayments, + billingAccountDetails.id, + ) + .map(getFinancePaymentSplit) + .filter((split): split is EngagementPaymentSplit => !!split) + + if (financeSplits.length === 0) { + return undefined + } + + const matchingPaymentSplits = financeSplits.filter(split => ( + split.challengeFee !== undefined + && currencyAmountsMatch(split.paymentAmount + split.challengeFee, item.amount) + )) + + if (matchingPaymentSplits.length === 1) { + return matchingPaymentSplits[0] + } + + const aggregateSplit = getAggregatePaymentSplit(financeSplits) + + if ( + aggregateSplit + && aggregateSplit.challengeFee !== undefined + && currencyAmountsMatch( + aggregateSplit.paymentAmount + aggregateSplit.challengeFee, + item.amount, + ) + ) { + return aggregateSplit + } + + if ( + aggregateSplit + && aggregateSplit.paymentAmount <= item.amount + && ( + financeSplits.length === 1 + || matchingPaymentSplits.length === 0 + ) + ) { + return { + challengeFee: roundCurrencyAmount(item.amount - aggregateSplit.paymentAmount), + paymentAmount: aggregateSplit.paymentAmount, + } + } + + return undefined +} + /** * Resolves the row challenge fee amount for callers allowed to see markup. * * @param item Raw locked or consumed billing-account line item. * @param displayAmount Member-payment amount selected for display. + * @param engagementPaymentSplit Exact finance payment split for engagement rows, when available. * @param billingAccountDetails Billing account detail payload containing hidden markup when available. * @param challengeDetailsById Hydrated challenge details, or `undefined` while loading. * @returns Persisted engagement or consumed challenge fee, calculated markup fee, or @@ -380,6 +639,7 @@ function getConsumedChallengeFeeAmount( function getLineItemChallengeFeeAmount( item: BillingAccountLineItem, displayAmount: number | undefined, + engagementPaymentSplit: EngagementPaymentSplit | undefined, billingAccountDetails: BillingAccountDetails, challengeDetailsById: ChallengeDetailsById | undefined, ): number | undefined { @@ -387,6 +647,10 @@ function getLineItemChallengeFeeAmount( return Number(item.challengeFee.toFixed(2)) } + if (engagementPaymentSplit?.challengeFee !== undefined) { + return engagementPaymentSplit.challengeFee + } + const consumedChallengeFeeAmount = getConsumedChallengeFeeAmount(item) if (consumedChallengeFeeAmount !== undefined) { @@ -405,6 +669,7 @@ function getLineItemChallengeFeeAmount( * * @param item Raw locked or consumed billing-account engagement line item. * @param billingAccountDetails Billing account detail payload containing markup when available. + * @param engagementPaymentSplit Exact finance payment split for engagement rows, when available. * @returns Member payment amount when it can be derived. * @remarks Engagement rows prefer persisted finance payment amounts, then * API-provided member-payment aliases. When only the billing-account charge @@ -413,11 +678,16 @@ function getLineItemChallengeFeeAmount( function getEngagementMemberPaymentAmount( item: BillingAccountLineItem, billingAccountDetails: BillingAccountDetails, + engagementPaymentSplit: EngagementPaymentSplit | undefined, ): number | undefined { if (item.paymentAmount !== undefined) { return item.paymentAmount } + if (engagementPaymentSplit?.paymentAmount !== undefined) { + return engagementPaymentSplit.paymentAmount + } + if (item.memberPaymentAmount !== undefined) { return item.memberPaymentAmount } @@ -440,10 +710,11 @@ function getLineItemMemberPaymentAmount( item: BillingAccountLineItem, billingAccountDetails: BillingAccountDetails, challengeDetailsById: ChallengeDetailsById | undefined, + engagementPaymentSplit: EngagementPaymentSplit | undefined, ): number | undefined { return item.externalType === 'CHALLENGE' ? getChallengeMemberPaymentAmount(item, billingAccountDetails, challengeDetailsById) - : getEngagementMemberPaymentAmount(item, billingAccountDetails) + : getEngagementMemberPaymentAmount(item, billingAccountDetails, engagementPaymentSplit) } /** @@ -452,6 +723,7 @@ function getLineItemMemberPaymentAmount( * @param item Raw locked or consumed billing-account line item. * @param billingAccountDetails Billing account detail payload containing hidden markup when available. * @param challengeDetailsById Hydrated challenge details, or `undefined` while loading. + * @param assignmentPaymentsById Finance payments keyed by engagement assignment id. * @param showChallengeFee Whether the caller can see billing challenge fees. * @returns A line item with `displayAmount` set to the visible member-payment * amount and, for non-copilots, `challengeFeeAmount` set to the billing markup fee. @@ -461,17 +733,25 @@ function getDisplayLineItem( item: BillingAccountLineItem, billingAccountDetails: BillingAccountDetails, challengeDetailsById: ChallengeDetailsById | undefined, + assignmentPaymentsById: AssignmentPaymentsById | undefined, showChallengeFee: boolean, ): BillingAccountModalLineItem { + const engagementPaymentSplit = getEngagementFinancePaymentSplit( + item, + billingAccountDetails, + assignmentPaymentsById, + ) const displayAmount = getLineItemMemberPaymentAmount( item, billingAccountDetails, challengeDetailsById, + engagementPaymentSplit, ) const challengeFeeAmount = showChallengeFee ? getLineItemChallengeFeeAmount( item, displayAmount, + engagementPaymentSplit, billingAccountDetails, challengeDetailsById, ) @@ -562,6 +842,10 @@ export const BillingAccountLineItemsModal: FC () => getChallengeLineItemIds(rawLineItems), [rawLineItems], ) + const engagementPaymentAssignmentIds = useMemo( + () => getEngagementPaymentAssignmentIds(rawLineItems), + [rawLineItems], + ) const challengeDetailsResult = useSWR( challengeLineItemIds.length > 0 ? ['work/billing-account-line-item-challenges', challengeLineItemIds.join(',')] @@ -575,6 +859,23 @@ export const BillingAccountLineItemsModal: FC const challengeDetailsById = challengeLineItemIds.length > 0 ? challengeDetailsResult.data : EMPTY_CHALLENGE_DETAILS_BY_ID + const assignmentPaymentsResult = useSWR( + engagementPaymentAssignmentIds.length > 0 + ? [ + 'work/billing-account-line-item-engagement-payments', + props.billingAccountDetails.id, + engagementPaymentAssignmentIds.join(','), + ] + : undefined, + () => fetchAssignmentPaymentsById(engagementPaymentAssignmentIds), + { + errorRetryCount: 2, + shouldRetryOnError: true, + }, + ) + const assignmentPaymentsById = engagementPaymentAssignmentIds.length > 0 + ? assignmentPaymentsResult.data + : EMPTY_ASSIGNMENT_PAYMENTS_BY_ID const lineItems = useMemo( () => rawLineItems @@ -582,9 +883,11 @@ export const BillingAccountLineItemsModal: FC item, props.billingAccountDetails, challengeDetailsById, + assignmentPaymentsById, showChallengeFeeColumn, )), [ + assignmentPaymentsById, challengeDetailsById, props.billingAccountDetails, rawLineItems, diff --git a/src/apps/work/src/lib/models/Engagement.model.ts b/src/apps/work/src/lib/models/Engagement.model.ts index f252100f8..5d9809b94 100644 --- a/src/apps/work/src/lib/models/Engagement.model.ts +++ b/src/apps/work/src/lib/models/Engagement.model.ts @@ -108,6 +108,8 @@ export interface AssignmentPayment { hoursWorked?: number | string remarks?: string } + billingAccountId?: number | string + challengeFee?: number | string createdBy?: string createdByHandle?: string createdAt?: string @@ -124,6 +126,7 @@ export interface AssignmentPayment { hoursWorked?: number | string id?: number | string paymentId?: number | string + paymentAmount?: number | string status?: string title?: string updatedAt?: string diff --git a/src/apps/work/src/lib/services/payments.service.spec.ts b/src/apps/work/src/lib/services/payments.service.spec.ts index cdca661f6..aea314dfb 100644 --- a/src/apps/work/src/lib/services/payments.service.spec.ts +++ b/src/apps/work/src/lib/services/payments.service.spec.ts @@ -7,6 +7,7 @@ import { fetchBillingAccountById, } from './billing-accounts.service' import { + fetchAssignmentPaymentSplits, fetchAssignmentPayments, } from './payments.service' import { @@ -89,3 +90,44 @@ describe('fetchAssignmentPayments', () => { ]) }) }) + +describe('fetchAssignmentPaymentSplits', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns raw payment rows without profile or billing-account hydration', async () => { + const mockedGetAsync = xhrGetAsync as jest.Mock + + mockedGetAsync.mockResolvedValue({ + data: [ + { + billingAccountId: '80001063', + challengeFee: '420.66', + paymentAmount: '342.00', + paymentId: 'payment-5245', + }, + ], + }) + + const result = await fetchAssignmentPaymentSplits('assignment / 5245') + + expect(mockedGetAsync) + .toHaveBeenCalledWith( + 'https://example.com/finance/winnings/by-external-id/assignment%20%2F%205245', + ) + expect(fetchBillingAccountById) + .not.toHaveBeenCalled() + expect(searchProfilesByUserIds) + .not.toHaveBeenCalled() + expect(result) + .toEqual([ + { + billingAccountId: '80001063', + challengeFee: '420.66', + paymentAmount: '342.00', + paymentId: 'payment-5245', + }, + ]) + }) +}) diff --git a/src/apps/work/src/lib/services/payments.service.ts b/src/apps/work/src/lib/services/payments.service.ts index f2394e593..ff08edf91 100644 --- a/src/apps/work/src/lib/services/payments.service.ts +++ b/src/apps/work/src/lib/services/payments.service.ts @@ -301,6 +301,37 @@ export async function fetchAssignmentPayments( } } +/** + * Fetches raw finance payments for an engagement assignment. + * + * @param assignmentId Engagement assignment id stored as the finance external id. + * @returns Normalized payment rows without creator or billing-account name hydration. + * @remarks Billing-account line-item reconciliation only needs payment amounts + * and fees, so this avoids the extra profile and billing-account lookups used + * by the full payment history modal. + * @throws Error when the finance request fails. + */ +export async function fetchAssignmentPaymentSplits( + assignmentId: number | string, +): Promise { + const normalizedAssignmentId = String(assignmentId) + .trim() + + if (!normalizedAssignmentId) { + return [] + } + + try { + const response = await xhrGetAsync( + `${TC_FINANCE_API_URL}/winnings/by-external-id/${encodeURIComponent(normalizedAssignmentId)}`, + ) + + return normalizePaymentsResponse(response) + } catch (error) { + throw normalizeError(error, 'Failed to fetch assignment payment splits') + } +} + export async function createPayment( paymentData: Record, ): Promise { diff --git a/src/apps/work/src/lib/utils/payment.utils.spec.ts b/src/apps/work/src/lib/utils/payment.utils.spec.ts index 9c0c24808..d89b1bd00 100644 --- a/src/apps/work/src/lib/utils/payment.utils.spec.ts +++ b/src/apps/work/src/lib/utils/payment.utils.spec.ts @@ -4,6 +4,7 @@ import type { } from '../models' import { calculatePaymentChallengeFee, + getPaymentAmount, getPaymentBillingAccountId, getPaymentBillingAccountName, getPaymentChallengeFee, @@ -32,6 +33,21 @@ describe('payment.utils', () => { .toBe(72) }) + it('reads top-level payment split fields from report-shaped payloads', () => { + const payment: AssignmentPayment = { + billingAccountId: '80004466', + challengeFee: '420.66', + paymentAmount: '342.00', + } + + expect(getPaymentAmount(payment)) + .toBe(342) + expect(getPaymentChallengeFee(payment)) + .toBe(420.66) + expect(getPaymentBillingAccountId(payment)) + .toBe('80004466') + }) + it('falls back to the total-versus-gross delta for older payment payloads', () => { const payment: AssignmentPayment = { details: [ diff --git a/src/apps/work/src/lib/utils/payment.utils.ts b/src/apps/work/src/lib/utils/payment.utils.ts index 95d758869..4ecb093ea 100644 --- a/src/apps/work/src/lib/utils/payment.utils.ts +++ b/src/apps/work/src/lib/utils/payment.utils.ts @@ -89,6 +89,10 @@ export function getPaymentAmount(payment: AssignmentPayment): number | undefined return toNumber(payment.amount) } + if (payment.paymentAmount !== undefined) { + return toNumber(payment.paymentAmount) + } + const firstDetail = getFirstPaymentDetail(payment) if (firstDetail) { @@ -114,8 +118,8 @@ export function getPaymentAmount(payment: AssignmentPayment): number | undefined * Resolves the persisted challenge fee associated with a payment. * * Engagement payments store the manager-entered payment amount separately from - * the billing-account fee. When finance returns the fee explicitly, this - * helper uses that field. For older payloads it falls back to a positive + * the billing-account fee. When finance or reports return the fee explicitly, + * this helper uses that field. For older payloads it falls back to a positive * `totalAmount - grossAmount` delta when present. * * @param payment payment record returned by the finance API. @@ -126,7 +130,7 @@ export function getPaymentChallengeFee( payment: AssignmentPayment, ): number | undefined { const firstDetail = getFirstPaymentDetail(payment) - const persistedChallengeFee = toNumber(firstDetail?.challengeFee) + const persistedChallengeFee = toNumber(payment.challengeFee ?? firstDetail?.challengeFee) if (persistedChallengeFee !== undefined && persistedChallengeFee >= 0) { return Number(persistedChallengeFee.toFixed(2)) @@ -195,7 +199,9 @@ export function getPaymentHoursWorked(payment: AssignmentPayment): string { * @throws This helper does not raise exceptions. */ export function getPaymentBillingAccountId(payment: AssignmentPayment): string { - return toOptionalDisplayString(getFirstPaymentDetail(payment)?.billingAccount) || '' + return toOptionalDisplayString( + payment.billingAccountId ?? getFirstPaymentDetail(payment)?.billingAccount, + ) || '' } /** diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index f513f8b09..f1646a505 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -105,6 +105,9 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha challenge-api-v6 fetch instead of reusing stale cached detail data, and the editor form reapplies that refreshed same-id snapshot once it arrives. - Save create/update/delete: `createChallenge`, `patchChallenge`, `deleteChallenge`. +- Manual saves for active scheduled challenges refetch the persisted challenge after `patchChallenge` + before resetting or navigating, so an API-rejected active-phase shortening is restored immediately + and the user sees a partial-save warning instead of a misleading success-only state. - Initial create refresh: after `createChallenge`, the form fetches full challenge details with `fetchChallenge` to avoid round-type regressions from sparse create responses and to surface the generated forum link for challenge types that provision a discussion on create. - Skills search: `searchSkills`. - Tracks fetch: `fetchChallengeTracks`. diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx index c1442e393..ebcd1bef1 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.spec.tsx @@ -64,6 +64,34 @@ let mockMaximumSubmissionsDeferDirtyValues: boolean[] = [] jest.mock('../../../../lib/components/form', () => ({ FormCheckboxField: () => <>, })) +jest.mock('../../../../lib/components', () => ({ + ConfirmationModal: (props: { + cancelText?: string + children?: React.ReactNode + confirmDisabled?: boolean + confirmText?: string + message: string + onCancel: () => void + onConfirm: () => void + title: string + }) => ( +
+

{props.title}

+

{props.message}

+ {props.children} + + +
+ ), +})) jest.mock('../../../../lib/hooks', () => ({ useAutosave: jest.fn(), useFetchChallengeTracks: jest.fn(), @@ -90,6 +118,19 @@ jest.mock('../../../../lib/services', () => ({ searchProfilesByUserIds: jest.fn(), })) jest.mock('../../../../lib/utils', () => ({ + checkCanEditProjectDetails: ( + userRoles?: string[], + userId?: number, + project?: { + members?: Array<{ + role?: string + userId?: number + }> + }, + ): boolean => (userRoles || []).includes('manager') + && (project?.members || []).some(member => ( + member.userId === userId && member.role === 'manager' + )), formatLastSaved: () => '', showErrorToast: jest.fn(), showSuccessToast: jest.fn(), @@ -219,6 +260,23 @@ jest.mock('~/config', () => ({ }), { virtual: true, }) +jest.mock('~/libs/core', () => ({ + xhrCreateInstance: jest.fn(() => ({ + defaults: { + headers: { + common: {}, + }, + }, + })), + xhrDeleteAsync: jest.fn(), + xhrGetAsync: jest.fn(), + xhrGetPaginatedAsync: jest.fn(), + xhrPatchAsync: jest.fn(), + xhrPostAsync: jest.fn(), + xhrPutAsync: jest.fn(), +}), { + virtual: true, +}) jest.mock('./AssignedMemberField', () => ({ AssignedMemberField: () => Assigned Member Field, })) @@ -674,6 +732,9 @@ const LocationDisplay = (): JSX.Element => { return
{location.pathname}
} +const activePhaseShorteningRejectionTestName = 'restores the persisted schedule when active phase shortening ' + + 'is rejected after saving other edits' + describe('ChallengeEditorForm', () => { const copilotContextValue: WorkAppContextModel = { isAdmin: false, @@ -3117,6 +3178,66 @@ describe('ChallengeEditorForm', () => { }) }) + it(activePhaseShorteningRejectionTestName, async () => { + const user = userEvent.setup() + const activeChallenge = { + ...validDraftChallenge, + legacy: { + reviewType: 'INTERNAL', + useSchedulingAPI: true, + }, + phases: [{ + duration: 1440, + isOpen: true, + name: 'Submission', + phaseId: 'submission-phase-id', + scheduledEndDate: '2026-04-19T04:58:51.000Z', + scheduledStartDate: '2026-04-11T04:58:51.000Z', + }], + startDate: '2026-04-11T04:58:51.000Z', + status: 'ACTIVE', + } as Challenge + const rejectedShortenedSchedule = { + ...activeChallenge, + name: 'Active challenge updated', + phases: [{ + ...activeChallenge.phases?.[0], + duration: 1440, + scheduledEndDate: '2026-04-18T04:58:51.000Z', + }], + } as Challenge + + mockedPatchChallenge.mockResolvedValue(rejectedShortenedSchedule) + mockedFetchChallenge.mockResolvedValue(activeChallenge) + + render( + + + + , + ) + + await user.click(screen.getByTestId('mock-dirty-phase-end')) + await user.type(screen.getByLabelText('Challenge Name'), ' updated') + await user.click(screen.getByRole('button', { name: 'Update Challenge' })) + + await waitFor(() => { + expect(mockedFetchChallenge) + .toHaveBeenCalledWith('12345') + expect(screen.getByTestId('challenge-schedule-section')) + .toHaveAttribute('data-first-phase-end', '2026-04-19T04:58:51.000Z') + expect(screen.getByTestId('location-display')) + .toHaveTextContent('/projects/100578/challenges/12345/view') + }) + expect(mockedShowErrorToast) + .toHaveBeenCalledWith('Active phase shortening cannot be saved. Other challenge changes were saved.') + expect(mockedShowSuccessToast) + .not.toHaveBeenCalledWith('Challenge saved successfully') + }) + it('blocks saving when an assigned AI workflow has been disabled', async () => { const user = userEvent.setup() diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx index 1b903e818..89089a862 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeEditorForm.tsx @@ -286,6 +286,8 @@ const DISABLED_AI_WORKFLOW_FOR_CHALLENGE_ACTION_MESSAGE const DISABLED_AI_TEMPLATE_FOR_CHALLENGE_ACTION_MESSAGE = 'The saved AI review template was disabled. ' + 'Update the AI template selection before saving or launching this challenge.' +const ACTIVE_PHASE_SHORTENING_NOT_SAVED_MESSAGE + = 'Active phase shortening cannot be saved. Other challenge changes were saved.' const CHALLENGE_TYPE_CHALLENGE_ABBREVIATION = 'CH' const CHALLENGE_TYPE_CHALLENGE_NAME = 'CHALLENGE' const CHALLENGE_TYPE_FIRST_2_FINISH_ABBREVIATION = 'F2F' @@ -1391,6 +1393,115 @@ function getSaveSuccessMessage( : 'Challenge saved successfully' } +/** + * Returns whether the saved challenge should be refetched before the form baseline is reset. + * + * @param formData form state that was just submitted to challenge-api. + * @returns `true` when an active challenge uses the scheduling API and has schedule phases. + */ +function shouldVerifyPersistedScheduleAfterSave( + formData: ChallengeEditorFormData, +): boolean { + return normalizeStatus(formData.status) === CHALLENGE_STATUS.ACTIVE + && formData.legacy?.useSchedulingAPI !== false + && Array.isArray(formData.phases) + && formData.phases.length > 0 +} + +/** + * Converts a phase date value to milliseconds for stable comparisons. + * + * @param value phase date value from form or API state. + * @returns the timestamp in milliseconds, or `undefined` when invalid. + */ +function getPhaseDateTime(value: Date | string | undefined): number | undefined { + if (!value) { + return undefined + } + + const parsedDate = value instanceof Date + ? value + : new Date(value) + + return Number.isNaN(parsedDate.getTime()) + ? undefined + : parsedDate.getTime() +} + +/** + * Resolves a stable phase identity for comparing submitted and persisted schedules. + * + * @param phase challenge phase from form or API state. + * @returns phase id token when available. + */ +function getPhaseIdentity(phase: ChallengePhase | undefined): string | undefined { + return normalizeTextValue(phase?.id) + || normalizeTextValue(phase?.phaseId) + || undefined +} + +/** + * Returns whether a phase should be treated as the currently open phase. + * + * @param phase challenge phase from form or API state. + * @returns `true` when the phase is explicitly open. + */ +function isOpenPhase(phase: ChallengePhase | undefined): boolean { + return phase?.isOpen === true + || normalizeTextValue(phase?.status) + .toUpperCase() === 'OPEN' +} + +/** + * Detects an active phase end date that the API did not shorten. + * + * @param submittedPhases schedule phases submitted with the save request. + * @param persistedPhases schedule phases fetched after the save completed. + * @returns `true` when an open phase still ends later in persisted data than in submitted data. + */ +function hasRejectedActivePhaseShortening( + submittedPhases: ChallengeEditorFormData['phases'], + persistedPhases: ChallengeEditorFormData['phases'], +): boolean { + if (!Array.isArray(submittedPhases) || !Array.isArray(persistedPhases)) { + return false + } + + const persistedPhasesByIdentity = new Map() + persistedPhases.forEach(phase => { + const phaseIdentity = getPhaseIdentity(phase) + if (phaseIdentity) { + persistedPhasesByIdentity.set(phaseIdentity, phase) + } + }) + + return submittedPhases.some((submittedPhase, index) => { + const phaseIdentity = getPhaseIdentity(submittedPhase) + const persistedPhase = phaseIdentity + ? persistedPhasesByIdentity.get(phaseIdentity) + : persistedPhases[index] + + if (!persistedPhase) { + return false + } + + if ( + (!isOpenPhase(submittedPhase) && !isOpenPhase(persistedPhase)) + || submittedPhase.actualEndDate + || persistedPhase.actualEndDate + ) { + return false + } + + const submittedEndTime = getPhaseDateTime(submittedPhase.scheduledEndDate) + const persistedEndTime = getPhaseDateTime(persistedPhase.scheduledEndDate) + + return submittedEndTime !== undefined + && persistedEndTime !== undefined + && submittedEndTime < persistedEndTime + }) +} + function getApprovalStatusText(approvalStatus: string | undefined): string { if (approvalStatus === CHALLENGE_APPROVAL_STATUS.APPROVED) { return 'Approved' @@ -2948,10 +3059,27 @@ export const ChallengeEditorForm: FC = ( }) const savedChallenge = await patchChallenge(currentChallengeId, payload) await syncDraftSingleAssignments(currentChallengeId, formDataWithProjectBilling) + const shouldVerifyPersistedSchedule + = shouldVerifyPersistedScheduleAfterSave(formDataWithProjectBilling) + let savedChallengeSnapshot = savedChallenge + + if (shouldVerifyPersistedSchedule) { + try { + savedChallengeSnapshot = await fetchChallenge(currentChallengeId) + } catch { + savedChallengeSnapshot = savedChallenge + } + } + const persistedFormData = applyProjectBillingToChallengeFormData( - transformChallengeToFormData(savedChallenge), + transformChallengeToFormData(savedChallengeSnapshot), resolvedProjectBillingAccount, ) + const wasActivePhaseShorteningRejected = shouldVerifyPersistedSchedule + && hasRejectedActivePhaseShortening( + formDataWithProjectBilling.phases, + persistedFormData.phases, + ) const nextValues = applySingleAssignmentFieldValues( await hydratePersistedSavedFormData( @@ -2984,7 +3112,11 @@ export const ChallengeEditorForm: FC = ( onChallengeStatusChange?.(normalizeStatus(nextValues.status)) if (!options.isAutosave) { - showSuccessToast(getSaveSuccessMessage(isSaveAsDraft, options)) + if (wasActivePhaseShorteningRejected) { + showErrorToast(ACTIVE_PHASE_SHORTENING_NOT_SAVED_MESSAGE) + } else { + showSuccessToast(getSaveSuccessMessage(isSaveAsDraft, options)) + } } const postSaveNavigationPath = resolvePostSaveNavigationPath({ 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 0fced2607..43b1cd7c0 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 @@ -149,13 +149,15 @@ } .fieldGroup, -.checkboxField { +.checkboxField, +.radioField { display: flex; flex-direction: column; gap: 8px; } -.fieldGroup span { +.fieldGroup span, +.radioField legend { color: $black-80; font-size: 13px; font-weight: 600; @@ -222,6 +224,35 @@ font-size: 14px; } +.radioField { + border: 0; + margin: 0; + padding: 0; +} + +.radioOptions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 16px; + min-height: 42px; +} + +.radioOption { + align-items: center; + color: $black-80; + cursor: pointer; + display: inline-flex; + font-size: 14px; + gap: 8px; +} + +.radioOption input { + accent-color: #137d60; + cursor: pointer; + margin: 0; +} + .fieldError { color: $red-100; font-size: 12px; diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.spec.tsx new file mode 100644 index 000000000..605b7e589 --- /dev/null +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.spec.tsx @@ -0,0 +1,225 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, unicorn/no-null */ +import '@testing-library/jest-dom' +import { + render, + screen, + waitFor, + within, +} from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { + ChallengePhase, + CreateMarathonMatchConfigInput, + MarathonMatchConfig, + MarathonMatchDefaults, + MarathonMatchTester, + MarathonMatchTesterSummary, +} from '../../../../../lib/models' +import { + createMarathonMatchConfig, + fetchMarathonMatchConfig, + fetchMarathonMatchDefaults, + fetchTester, + fetchTesters, +} from '../../../../../lib/services' + +import { MarathonMatchScorerSection } from './MarathonMatchScorerSection' + +jest.mock('~/libs/ui', () => ({ + BaseModal: (props: { + buttons?: JSX.Element + children?: JSX.Element + open?: boolean + title?: string + }) => (props.open + ? ( +
+ {props.title ?

{props.title}

: undefined} + {props.children} + {props.buttons} +
+ ) + : null), + Button: (props: { + disabled?: boolean + label: string + onClick?: () => void + type?: 'button' | 'submit' + }) => ( + + ), +}), { + virtual: true, +}) + +jest.mock('../../../../../lib/services', () => ({ + createMarathonMatchConfig: jest.fn(), + fetchMarathonMatchConfig: jest.fn(), + fetchMarathonMatchDefaults: jest.fn(), + fetchMarathonMatchTestSubmissionStatus: jest.fn(), + fetchTester: jest.fn(), + fetchTesters: jest.fn(), + rerunMarathonMatchScores: jest.fn(), + updateMarathonMatchConfig: jest.fn(), + uploadMarathonMatchTestSubmission: jest.fn(), +})) + +jest.mock('../../../../../lib/utils', () => ({ + formatDateTime: (value: unknown): string => String(value || ''), + showErrorToast: jest.fn(), + showInfoToast: jest.fn(), + showSuccessToast: jest.fn(), +})) + +jest.mock('./TesterModal', () => ({ + TesterModal: () => null, +})) + +const CHALLENGE_ID = 'challenge-1' + +const phases: ChallengePhase[] = [ + { + id: 'phase-example', + name: 'Submission', + phaseId: 'phase-example', + }, + { + id: 'phase-review', + name: 'Review', + phaseId: 'phase-review', + }, +] + +const defaults: MarathonMatchDefaults = { + compileTimeout: 300000, + reviewScorecardId: 'scorecard-1', + taskDefinitionName: 'runner-task', + taskDefinitionVersion: '1', + testTimeout: 600000, +} + +const testerSummary: MarathonMatchTesterSummary = { + className: 'ExampleScorer', + compilationError: null, + compilationStatus: 'SUCCESS', + createdAt: '2026-06-24T00:00:00.000Z', + id: 'tester-1', + name: 'Example Scorer', + updatedAt: '2026-06-24T00:00:00.000Z', + version: '1.0.0', +} + +const tester: MarathonMatchTester = { + ...testerSummary, + sourceCode: 'public class ExampleScorer {}', +} + +const mockCreateMarathonMatchConfig = createMarathonMatchConfig as jest.MockedFunction< + typeof createMarathonMatchConfig +> +const mockFetchMarathonMatchConfig = fetchMarathonMatchConfig as jest.MockedFunction< + typeof fetchMarathonMatchConfig +> +const mockFetchMarathonMatchDefaults = fetchMarathonMatchDefaults as jest.MockedFunction< + typeof fetchMarathonMatchDefaults +> +const mockFetchTester = fetchTester as jest.MockedFunction +const mockFetchTesters = fetchTesters as jest.MockedFunction + +function buildSavedConfig( + challengeId: string, + input: CreateMarathonMatchConfigInput, +): MarathonMatchConfig { + return { + active: input.active !== false, + challengeId, + compileTimeout: input.compileTimeout, + createdAt: '2026-06-24T00:00:00.000Z', + example: input.example || null, + id: 'config-1', + name: input.name, + provisional: input.provisional || null, + relativeScoringEnabled: input.relativeScoringEnabled !== false, + reviewScorecardId: input.reviewScorecardId, + scoreDirection: input.scoreDirection || 'MAXIMIZE', + system: input.system || null, + taskDefinitionName: input.taskDefinitionName, + taskDefinitionVersion: input.taskDefinitionVersion, + testerId: input.testerId, + testTimeout: input.testTimeout, + updatedAt: '2026-06-24T00:00:00.000Z', + } +} + +describe('MarathonMatchScorerSection', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockFetchMarathonMatchDefaults.mockResolvedValue(defaults) + mockFetchMarathonMatchConfig.mockResolvedValue(undefined) + mockFetchTesters.mockResolvedValue([testerSummary]) + mockFetchTester.mockResolvedValue(tester) + mockCreateMarathonMatchConfig.mockImplementation( + async (challengeId, input) => buildSavedConfig(challengeId, input), + ) + }) + + it('defaults to Maximize and saves the selected Minimize score direction', async () => { + const user = userEvent.setup() + + render( + , + ) + + const scoreDirectionGroup = await screen.findByRole('group', { + name: 'Score Direction', + }) + const maximizeOption = within(scoreDirectionGroup) + .getByRole('radio', { name: 'Maximize' }) + const minimizeOption = within(scoreDirectionGroup) + .getByRole('radio', { name: 'Minimize' }) + + expect(maximizeOption) + .toBeChecked() + expect(minimizeOption) + .not + .toBeChecked() + + await user.selectOptions( + screen.getByRole('combobox', { name: /Scorer/ }), + testerSummary.id, + ) + await user.click(minimizeOption) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Save Scorer Config' })) + .toBeEnabled() + }) + + await user.click(screen.getByRole('button', { name: 'Save Scorer Config' })) + + await waitFor(() => { + expect(mockCreateMarathonMatchConfig) + .toHaveBeenCalledWith( + CHALLENGE_ID, + expect.objectContaining({ + scoreDirection: 'MINIMIZE', + testerId: testerSummary.id, + }), + ) + }) + }) +}) 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 dbacab6fa..7ac0570a9 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 @@ -18,6 +18,7 @@ import { MarathonMatchConfigType, MarathonMatchDefaults, MarathonMatchPhaseConfig, + MarathonMatchScoreDirection, MarathonMatchTester, MarathonMatchTesterSummary, MarathonMatchTestSubmissionResponse, @@ -76,6 +77,19 @@ const PHASE_DEFAULTS = { startSeed: 1651246628, }, } as const +const SCORE_DIRECTION_OPTIONS: Array<{ + label: string + value: MarathonMatchScoreDirection +}> = [ + { + label: 'Maximize', + value: 'MAXIMIZE', + }, + { + label: 'Minimize', + value: 'MINIMIZE', + }, +] type PhaseDraftKey = keyof typeof PHASE_LABELS @@ -1091,6 +1105,22 @@ export const MarathonMatchScorerSection: FC = ( [updateDraft], ) + /** + * Updates the scorer draft with the selected score direction. + * @param event Radio change event carrying the selected marathon match score direction. + * @returns void + * Used by the Score Direction radios before persisting the scorer config payload. + */ + const handleScoreDirectionChange = useCallback( + (event: ChangeEvent): void => { + updateDraft(currentDraft => ({ + ...currentDraft, + scoreDirection: event.target.value as MarathonMatchScoreDirection, + })) + }, + [updateDraft], + ) + const handleNumericFieldChange = useCallback( (field: 'compileTimeout' | 'testTimeout') => ( event: ChangeEvent, @@ -1999,6 +2029,29 @@ export const MarathonMatchScorerSection: FC = ( Relative Scoring +
+ Score Direction +
+ {SCORE_DIRECTION_OPTIONS.map(option => ( + + ))} +
+
+