From dd1da027e65d710364c8f3a5c68c53c1a0385ca0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 24 Jun 2026 16:26:05 +1000 Subject: [PATCH 1/6] PM-5245: Use finance splits in billing modal What was broken The billing account details modal could still show the markup-derived engagement member payment and challenge fee when billing-account rows only exposed a derived memberPaymentAmount. QA still saw the $753.42 / $9.24 split instead of the finance split. Root cause The prior fix only preserved exact split fields when they were already present on billing-account line items. For engagement rows that only carry an assignment id and combined ledger amount, the API-provided memberPaymentAmount can be derived from billing markup and is wrong for payments that used an absolute finance challenge fee. What was changed Added a lightweight raw finance payment split fetch for engagement assignments and made the billing modal prefer matching finance paymentAmount/challengeFee values before falling back to billing-account fields or markup math. Payment helpers now also read report-shaped top-level paymentAmount, challengeFee, and billingAccountId fields. Any added/updated tests Added modal regression coverage for the QA-failed split, service coverage for the raw payment split fetch, and utility coverage for top-level payment split fields. --- .../BillingAccountLineItemsModal.spec.tsx | 51 +++ .../BillingAccountLineItemsModal.tsx | 307 +++++++++++++++++- .../work/src/lib/models/Engagement.model.ts | 3 + .../src/lib/services/payments.service.spec.ts | 42 +++ .../work/src/lib/services/payments.service.ts | 31 ++ .../work/src/lib/utils/payment.utils.spec.ts | 16 + src/apps/work/src/lib/utils/payment.utils.ts | 14 +- 7 files changed, 458 insertions(+), 6 deletions(-) diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx index 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, + ) || '' } /** From a23c4e5b98f500ee3db7e6a7ac3584c40d9a336a Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 24 Jun 2026 16:36:06 +1000 Subject: [PATCH 2/6] PM-5442: Match rating marker line color What was broken The rating distribution marker line in the member profile rating modal was always yellow, even when the member rating color was red, blue, green, or gray. Root cause The marker line is rendered by a CSS pseudo-element with a hardcoded yellow background, while the nearby rating values already use getRatingColor. What was changed The modal now computes the rating color once and applies it to the marker element. The marker line uses currentColor so it stays in sync with the displayed rating color. Any added/updated tests Updated MemberRatingInfoModal.spec.tsx to assert the marker receives the mocked rating color. Focused modal tests pass. The full yarn test:no-watch command was run and fails in unrelated work and wallet-admin suites that are outside this change. --- .../MemberRatingInfoModal.module.scss | 2 +- .../MemberRatingInfoModal.spec.tsx | 11 ++++++++++- .../MemberRatingInfoModal/MemberRatingInfoModal.tsx | 13 +++++++------ 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.module.scss index 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}
From c6098aec60bcc3e928e57a344e8b9f43d931b427 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 24 Jun 2026 16:55:34 +1000 Subject: [PATCH 3/6] PM-5446: Restore persisted active schedules What was broken - Active challenge saves could show a success-only state after an attempted active phase shortening, even when challenge-api kept the original timeline. Root cause - The editor reset and navigated with the patch response, which could echo submitted phase dates instead of the persisted schedule state. What was changed - Active scheduled challenge saves now refetch the persisted challenge before resetting or navigating. - Rejected active-phase shortening is detected from the fresh schedule and shown as a partial-save warning while other saved fields remain saved. - Challenge editor docs were updated for the post-save schedule verification behavior. Any added/updated tests - Added a ChallengeEditorForm regression test for a patch response that echoes shortened phase dates while the persisted timeline remains unchanged. - Validation run: focused PM-5446 test passed; lint passed; build passed with existing warnings. Full yarn test:no-watch --runInBand still fails on unrelated existing wallet-admin alias/icon issues and pre-existing launch approval expectations. --- .../challenges/ChallengeEditorPage/README.md | 3 + .../components/ChallengeEditorForm.spec.tsx | 121 ++++++++++++++++ .../components/ChallengeEditorForm.tsx | 136 +++++++++++++++++- 3 files changed, 258 insertions(+), 2 deletions(-) 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({ From d7405d56671939ca69d59c419b84cb10d2770c7b Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 25 Jun 2026 09:02:43 +1000 Subject: [PATCH 4/6] PM-5444: Show top percentile for highest profile ratings What was broken Profiles with a positive rating above the highest returned rating distribution bucket showed the rating value but omitted the Top X% percentile badge. Root cause The percentile calculation returned undefined when no distribution bucket contained or exceeded the member rating. For outlier ratings above the final bucket, that suppressed the badge even though the member should still be shown as a top-ranked member. What was changed Treat ratings above the final distribution bucket as the top known member percentage so the existing formatter renders a visible Top 1% badge instead of hiding the percentile details. Any added/updated tests Added utility coverage for ratings above the highest distribution range and component coverage for rendering Top 1% with a 4051 profile rating. --- .../MemberRatingCard.spec.tsx | 25 +++++++++++++++++++ .../MemberRatingCard.utils.spec.ts | 5 ++++ .../MemberRatingCard.utils.ts | 8 +++++- 3 files changed, 37 insertions(+), 1 deletion(-) 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 } From e72c274e9f36f03281d64e7266acd438bea6b4c8 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 25 Jun 2026 09:23:43 +1000 Subject: [PATCH 5/6] PM-5437: Deduplicate development profile stats What was broken Development totals on profiles added the AI Engineering and Challenge subtrack counters together, so the same Dev-track AI challenges could be counted twice. Root cause AI Engineering is displayed under Development from the DATA_SCIENCE rating path, while overlapping Dev Challenge history remains visible as a Development subtrack. Parent totals were simple subtrack sums and ignored shared challenge history. What was changed Added history-aware parent summary calculation for Development, keyed by challenge history identity, and used it in active track aggregation and the Development drilldown. Non-Development drilldowns keep the existing summing behavior. Any added/updated tests Added a profiles hook regression test covering AI Engineering and Challenge histories sharing the same Dev AI challenges. --- .../src/hooks/useFetchActiveTracks.spec.tsx | 104 ++++++++++++- .../src/hooks/useFetchActiveTracks.tsx | 146 ++++++++++++++++-- .../tc-achievements/track-view/TrackView.tsx | 24 ++- 3 files changed, 255 insertions(+), 19 deletions(-) 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/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() : (
From bc233d31688ceb96f1dca0c3618ffcf21040667f Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 25 Jun 2026 09:35:49 +1000 Subject: [PATCH 6/6] PM-5454: Add score direction radios to MM scorer config What was broken The Marathon Match scorer config editor already loaded and saved scoreDirection, but operators had no field in the Work app to choose MAXIMIZE or MINIMIZE. Root cause (if identifiable) The UI draft and API payload included scoreDirection defaults, but the Scorer Settings form did not render a control for it. What was changed Added a Score Direction radio group with Maximize and Minimize options, defaulting to Maximize when the draft has no saved direction. Wired the radio choice into the existing scorer config draft and save payload. Added local styles matching the existing settings grid. Any added/updated tests Added a MarathonMatchScorerSection test covering the default Maximize selection and saving Minimize to the create config payload. --- .../MarathonMatchScorerSection.module.scss | 35 ++- .../MarathonMatchScorerSection.spec.tsx | 225 ++++++++++++++++++ .../MarathonMatchScorerSection.tsx | 53 +++++ 3 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 src/apps/work/src/pages/challenges/ChallengeEditorPage/components/MarathonMatchScorerSection/MarathonMatchScorerSection.spec.tsx 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 => ( + + ))} +
+
+