diff --git a/src/apps/customer-portal/src/lib/services/statistics.service.ts b/src/apps/customer-portal/src/lib/services/statistics.service.ts
index 3dbabc006..e15a59c15 100644
--- a/src/apps/customer-portal/src/lib/services/statistics.service.ts
+++ b/src/apps/customer-portal/src/lib/services/statistics.service.ts
@@ -157,11 +157,6 @@ function normalizeCountryRows(
return
}
- if (row['country.country_name'] === 'Taiwan') {
- console.log('here', row)
-
- }
-
const code = toAlpha2CountryCode(lookup.countryCode)
const current = countries.get(code)
const topWinners = (row.topWinners || [])
diff --git a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss
index 9c9b47fea..087ba9099 100644
--- a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss
+++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.module.scss
@@ -296,6 +296,12 @@
}
}
+ :global(.highcharts-tooltip) {
+ position: fixed !important;
+ transform: none !important;
+ z-index: 20;
+ }
+
&:fullscreen {
height: 100vh;
min-height: 100vh;
@@ -306,6 +312,12 @@
height: 100vh !important;
}
}
+
+ @include ltesm {
+ :global(.highcharts-legend-item text) {
+ font-size: 10px !important;
+ }
+ }
}
.mapFullscreenButton {
@@ -367,6 +379,9 @@
top: 100%;
transform: translateX(-50%);
width: 0;
+ @include ltemd {
+ display: none;
+ }
}
}
diff --git a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx
index 8e9b901e8..e02db6ffe 100644
--- a/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx
+++ b/src/apps/customer-portal/src/pages/statistics/StatisticsPage/StatisticsPage.tsx
@@ -237,7 +237,7 @@ const StatisticsPage: FC = () => {
-
+
{country.code && (
,
): Highcharts.TooltipFormatterCallbackFunction {
@@ -481,14 +505,14 @@ const WorldMap: FC = props => {
margin: [0, 0, 80, 0],
},
legend: {
- itemDistance: 24,
+ itemDistance: 16,
itemMarginBottom: 4,
itemStyle: {
- fontSize: '12px',
+ fontSize: '9px',
},
symbolHeight: 12,
symbolWidth: 12,
- width: 340,
+ width: 360,
},
},
condition: {
@@ -515,7 +539,28 @@ const WorldMap: FC = props => {
showWinnerDetails: props.showWinnerDetails,
}),
padding: 0,
+ positioner: (
+ labelWidth: number,
+ labelHeight: number,
+ point: Highcharts.Point,
+ ): Highcharts.PositionObject => {
+ const chart = chartRef.current?.chart as Highcharts.Chart | undefined
+
+ if (!chart) {
+ return { x: 0, y: 0 }
+ }
+
+ return getFixedTooltipPosition(
+ labelWidth,
+ labelHeight,
+ point,
+ chart,
+ )
+ },
shadow: false,
+ style: {
+ position: 'fixed',
+ },
useHTML: true,
},
}),
diff --git a/src/apps/profiles/src/components/MemberNotFound/MemberNotFound.module.scss b/src/apps/profiles/src/components/MemberNotFound/MemberNotFound.module.scss
new file mode 100644
index 000000000..6fab0c29a
--- /dev/null
+++ b/src/apps/profiles/src/components/MemberNotFound/MemberNotFound.module.scss
@@ -0,0 +1,31 @@
+@import "@libs/ui/styles/includes";
+
+.container {
+ min-height: 60vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ gap: $sp-6;
+ padding: $sp-15 $sp-8;
+ color: $black-100;
+
+ svg {
+ color: $black-60;
+ }
+}
+
+.title {
+ @include font-barlow;
+ font-weight: 600;
+ font-size: 28px;
+ line-height: 34px;
+ margin: 0;
+}
diff --git a/src/apps/profiles/src/components/MemberNotFound/MemberNotFound.tsx b/src/apps/profiles/src/components/MemberNotFound/MemberNotFound.tsx
new file mode 100644
index 000000000..24130076f
--- /dev/null
+++ b/src/apps/profiles/src/components/MemberNotFound/MemberNotFound.tsx
@@ -0,0 +1,48 @@
+import { FC, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+
+import { Button, ContentLayout, IconOutline, IconSolid, PageTitle } from '~/libs/ui'
+
+import styles from './MemberNotFound.module.scss'
+
+interface MemberNotFoundProps {
+ memberHandle?: string
+}
+
+const MemberNotFound: FC = (props: MemberNotFoundProps) => {
+ const navigate = useNavigate()
+
+ const handleBack = useCallback(() => {
+ navigate(-1)
+ }, [navigate])
+
+ return (
+
+ Profile Not Found | Topcoder
+
+
+
+ We were unable to locate that profile
+ {props.memberHandle && (
+
+ No member was found with the handle
+ {' '}
+ {props.memberHandle}
+ .
+
+ )}
+
+
+
+ )
+}
+
+export default MemberNotFound
diff --git a/src/apps/profiles/src/components/MemberNotFound/index.ts b/src/apps/profiles/src/components/MemberNotFound/index.ts
new file mode 100644
index 000000000..e48b4584b
--- /dev/null
+++ b/src/apps/profiles/src/components/MemberNotFound/index.ts
@@ -0,0 +1 @@
+export { default as MemberNotFound } from './MemberNotFound'
diff --git a/src/apps/profiles/src/components/index.ts b/src/apps/profiles/src/components/index.ts
index 8bb7e5e34..bed93b6c6 100644
--- a/src/apps/profiles/src/components/index.ts
+++ b/src/apps/profiles/src/components/index.ts
@@ -4,3 +4,4 @@ export * from './tc-achievements/SRMView/ChallengesGrid'
export * from './EditMemberPropertyBtn'
export * from './AddButton'
export * from './EmptySection'
+export * from './MemberNotFound'
diff --git a/src/apps/profiles/src/member-badges/MemberBadgesPage.tsx b/src/apps/profiles/src/member-badges/MemberBadgesPage.tsx
index ae271f591..d55e3bac3 100644
--- a/src/apps/profiles/src/member-badges/MemberBadgesPage.tsx
+++ b/src/apps/profiles/src/member-badges/MemberBadgesPage.tsx
@@ -1,11 +1,12 @@
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useState } from 'react'
import { Params, useNavigate, useParams } from 'react-router-dom'
+import { AxiosError } from 'axios'
import { bind } from 'lodash'
import { profileGetPublicAsync, useMemberBadges, UserBadge, UserBadgesResponse, UserProfile } from '~/libs/core'
import { Button, ContentLayout, IconSolid, LoadingSpinner } from '~/libs/ui'
-import { MemberBadgeModal } from '../components'
+import { MemberBadgeModal, MemberNotFound } from '../components'
import styles from './MemberBadgesPage.module.scss'
@@ -19,6 +20,7 @@ const MemberBadgesPage: FC<{}> = () => {
] = useState()
const [profileReady, setProfileReady]: [boolean, Dispatch>] = useState(false)
+ const [notFound, setNotFound]: [boolean, Dispatch>] = useState(false)
const memberBadges: UserBadgesResponse | undefined = useMemberBadges(profile?.userId as number, { limit: 100 })
@@ -39,12 +41,21 @@ const MemberBadgesPage: FC<{}> = () => {
useEffect(() => {
if (routeParams.memberHandle) {
+ setProfileReady(false)
+ setNotFound(false)
+ setProfile(undefined)
+
profileGetPublicAsync(routeParams.memberHandle)
.then(userProfile => {
setProfile(userProfile)
setProfileReady(true)
})
- // TODO: NOT FOUND PAGE redirect/dispaly via catch
+ .catch((e: AxiosError) => {
+ if (e.code === AxiosError.ERR_BAD_REQUEST && e.response?.status === 404) {
+ setNotFound(true)
+ setProfileReady(true)
+ }
+ })
}
}, [routeParams.memberHandle])
@@ -54,9 +65,13 @@ const MemberBadgesPage: FC<{}> = () => {
return (
<>
-
+
+
+ {profileReady && notFound && (
+
+ )}
- {profileReady && profile && !!memberBadges && (
+ {profileReady && profile && !notFound && !!memberBadges && (
diff --git a/src/apps/profiles/src/member-profile/MemberProfilePage.tsx b/src/apps/profiles/src/member-profile/MemberProfilePage.tsx
index d5a85cf72..7bdead5cb 100644
--- a/src/apps/profiles/src/member-profile/MemberProfilePage.tsx
+++ b/src/apps/profiles/src/member-profile/MemberProfilePage.tsx
@@ -5,7 +5,7 @@ import { AxiosError } from 'axios'
import { profileContext, ProfileContextData, profileGetPublicAsync, UserProfile } from '~/libs/core'
import { LoadingSpinner } from '~/libs/ui'
-import { rootRoute } from '../profiles.routes'
+import { MemberNotFound } from '../components'
import { notifyUniNavi } from '../lib'
import { ProfilePageLayout } from './page-layout'
@@ -21,6 +21,7 @@ const MemberProfilePage: FC<{}> = () => {
] = useState()
const [profileReady, setProfileReady]: [boolean, Dispatch>] = useState(false)
+ const [notFound, setNotFound]: [boolean, Dispatch>] = useState(false)
const { isTalentSearch }: MemberProfileContextValue = useMemberProfileContext()
const { profile: authProfile }: ProfileContextData = useContext(profileContext)
@@ -31,6 +32,10 @@ const MemberProfilePage: FC<{}> = () => {
useEffect(() => {
if (routeParams.memberHandle) {
+ setProfileReady(false)
+ setNotFound(false)
+ setProfile(undefined)
+
profileGetPublicAsync(routeParams.memberHandle)
.then(userProfile => {
setProfile({ ...userProfile } as UserProfile)
@@ -38,7 +43,8 @@ const MemberProfilePage: FC<{}> = () => {
})
.catch((e: AxiosError) => {
if (e.code === AxiosError.ERR_BAD_REQUEST && e.response?.status === 404) {
- navigate(rootRoute)
+ setNotFound(true)
+ setProfileReady(true)
}
})
}
@@ -58,7 +64,11 @@ const MemberProfilePage: FC<{}> = () => {
<>
- {profileReady && profile && (
+ {profileReady && notFound && (
+
+ )}
+
+ {profileReady && profile && !notFound && (
({
})
jest.mock('../../../components', () => ({
+ AddButton: (props: { label: string, onClick: () => void }): JSX.Element => (
+
+ ),
EditMemberPropertyBtn: () => ,
}))
@@ -116,7 +119,7 @@ describe('MemberRatingCard', () => {
.toBeInTheDocument()
})
- it('shows the preferred roles edit action for the profile owner when rating data is unavailable', () => {
+ it('shows the preferred roles add action for the profile owner when rating data is unavailable', () => {
mockedUseMemberStats.mockReturnValue(undefined)
render()
@@ -126,7 +129,8 @@ describe('MemberRatingCard', () => {
.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add preferred roles' }))
.toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'Edit' }))
+ expect(screen.queryByRole('button', { name: 'Edit' }))
+ .not
.toBeInTheDocument()
})
diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx
index 773b35a28..eae4d58fb 100644
--- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx
+++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingCard.tsx
@@ -14,7 +14,7 @@ import {
} from '~/libs/core'
import { Tooltip } from '~/libs/ui'
-import { EditMemberPropertyBtn } from '../../../components'
+import { AddButton, EditMemberPropertyBtn } from '../../../components'
import { getPreferredRolesText } from '../../../lib'
import {
@@ -118,35 +118,38 @@ const MemberRatingCard: FC = (props: MemberRatingCardProp
return <>>
}
+ if (preferredRoles.length === 0) {
+ return (
+
+ )
+ }
+
return (
- {preferredRoles.length > 0 ? (
-
- {preferredRolesDisplay.visibleRoles.map((role: string) => (
-
- {role}
-
- ))}
-
- {preferredRolesDisplay.toggleLabel && (
-
- )}
-
- ) : (
-
- )}
+
+ {preferredRolesDisplay.visibleRoles.map((role: string) => (
+
+ {role}
+
+ ))}
+
+ {preferredRolesDisplay.toggleLabel && (
+
+ )}
+
{canEditPreferredRoles && (
({
getRatingColor: jest.fn(),
}), {
@@ -154,8 +166,69 @@ describe('MemberRatingInfoModal', () => {
expect(marker)
.toHaveStyle('color: #616BD5')
expect(parseFloat(marker.style.left))
- .toBeLessThan(80)
+ .toBeCloseTo((5.5 / 6) * 100)
+ expect(marker)
+ .toHaveClass('memberMarkerStacked')
+ })
+
+ it('centers the marker on the final module bar when the rating exceeds the distribution', () => {
+ render(
+ ,
+ )
+
+ const marker = screen.getByTestId('rating-member-marker')
+
+ expect(parseFloat(marker.style.left))
+ .toBeCloseTo((5.5 / 6) * 100)
expect(marker)
.toHaveClass('memberMarkerStacked')
})
+
+ it('removes trailing empty buckets so the histogram spans the chart', () => {
+ render(
+ ,
+ )
+
+ const marker = screen.getByTestId('rating-member-marker')
+ const bars = document.querySelector('[class*="bars"]') as HTMLElement
+
+ expect(bars)
+ .not
+ .toHaveAttribute('style')
+ expect(parseFloat(marker.style.left))
+ .toBeCloseTo((3.5 / 6) * 100)
+ })
+
+ it('hides the 2200+ axis label when the distribution has no elite buckets', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('1500'))
+ .toBeInTheDocument()
+ expect(screen.queryByText('2200+'))
+ .not
+ .toBeInTheDocument()
+ })
})
diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/MemberRatingInfoModal/MemberRatingInfoModal.tsx
index 7e06eb160..d3a4ca25e 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
@@ -180,58 +180,161 @@ const getDistributionRanges = (
)
/**
- * Returns the chart end rating used for marker and axis positioning.
- *
- * Used by MemberRatingInfoModal to align labels with the rendered distribution range.
+ * Trims trailing empty rating buckets so the histogram can span the chart width.
*
* @param {RatingDistributionRange[]} ranges - Parsed rating distribution ranges.
- * @returns {number} The maximum rating represented by the chart.
+ * @returns {RatingDistributionRange[]} Ranges through the last populated bucket.
*/
-const getChartEndRating = (ranges: RatingDistributionRange[]): number => {
+const getVisibleDistributionRanges = (
+ ranges: RatingDistributionRange[],
+): RatingDistributionRange[] => {
if (ranges.length === 0) {
- return 3999
+ return ranges
+ }
+
+ let lastPopulatedIndex = -1
+ for (let index = ranges.length - 1; index >= 0; index -= 1) {
+ if (ranges[index].value > 0) {
+ lastPopulatedIndex = index
+ break
+ }
}
- return ranges[ranges.length - 1].end
+ if (lastPopulatedIndex < 0) {
+ return ranges
+ }
+
+ return ranges.slice(0, lastPopulatedIndex + 1)
}
/**
- * Calculates a horizontal chart position for a rating value.
+ * Returns the histogram bar index for a rating value.
*
- * Used by MemberRatingInfoModal for the member marker and static x-axis labels.
+ * @param {number} rating - The rating value to locate.
+ * @param {RatingDistributionRange[]} ranges - Visible rating distribution ranges.
+ * @returns {number} Zero-based bar index, clamped to the visible range.
+ */
+const getBarIndexForRating = (
+ rating: number,
+ ranges: RatingDistributionRange[],
+): number => {
+ if (ranges.length === 0) {
+ return 0
+ }
+
+ const matchingIndex = ranges.findIndex((range: RatingDistributionRange) => (
+ rating >= range.start && rating <= range.end
+ ))
+
+ if (matchingIndex >= 0) {
+ return matchingIndex
+ }
+
+ if (rating < ranges[0].start) {
+ return 0
+ }
+
+ return ranges.length - 1
+}
+
+/**
+ * Returns the horizontal center position of the histogram bar for a rating.
+ *
+ * Used by MemberRatingInfoModal so the marker sits on the module bar itself
+ * instead of interpolating across a separate rating-percentage scale.
*
* @param {number} rating - The rating value to position.
- * @param {RatingDistributionRange[]} ranges - Parsed rating distribution ranges.
- * @returns {number} A clamped percentage from 0 to 100.
+ * @param {RatingDistributionRange[]} ranges - Visible rating distribution ranges.
+ * @returns {number} Center position of the matching bar as a percentage from 0 to 100.
*/
-const getChartPosition = (rating: number, ranges: RatingDistributionRange[]): number => {
- const chartStart = ranges[0]?.start ?? 0
- const chartEnd = getChartEndRating(ranges)
- const chartSpan = chartEnd - chartStart
+const getMarkerPosition = (
+ rating: number,
+ ranges: RatingDistributionRange[],
+): number => {
+ if (ranges.length === 0) {
+ return 0
+ }
+
+ const barIndex = getBarIndexForRating(rating, ranges)
- if (chartSpan <= 0) {
+ return ((barIndex + 0.5) / ranges.length) * 100
+}
+
+/**
+ * Returns the horizontal start position of the histogram bar for an axis label.
+ *
+ * @param {number} rating - The axis label rating value.
+ * @param {RatingDistributionRange[]} ranges - Visible rating distribution ranges.
+ * @returns {number} Start position of the matching bar as a percentage from 0 to 100.
+ */
+const getAxisLabelPosition = (
+ rating: number,
+ ranges: RatingDistributionRange[],
+): number => {
+ if (ranges.length === 0) {
return 0
}
- const clampedRating = Math.max(chartStart, Math.min(rating, chartEnd))
+ const matchingIndex = ranges.findIndex((range: RatingDistributionRange) => (
+ rating >= range.start && rating <= range.end
+ ))
- return ((clampedRating - chartStart) / chartSpan) * 100
+ if (matchingIndex >= 0) {
+ return (matchingIndex / ranges.length) * 100
+ }
+
+ const nextIndex = ranges.findIndex((range: RatingDistributionRange) => range.start >= rating)
+
+ if (nextIndex >= 0) {
+ return (nextIndex / ranges.length) * 100
+ }
+
+ return 100
+}
+
+/**
+ * Returns axis labels that fall within the visible distribution ranges.
+ *
+ * Hides labels such as `2200+` when the trimmed histogram has no elite buckets,
+ * which otherwise stack on top of the final label (e.g. `1500`).
+ *
+ * @param {RatingDistributionRange[]} ranges - Visible rating distribution ranges.
+ * @returns {Array<{ label: string, value: number }>} Axis labels to render.
+ */
+const getVisibleAxisLabels = (
+ ranges: RatingDistributionRange[],
+): Array<{ label: string, value: number }> => {
+ if (ranges.length === 0) {
+ return chartAxisLabels
+ }
+
+ const chartEnd = ranges[ranges.length - 1].end
+
+ return chartAxisLabels.filter((axisLabel: { label: string, value: number }) => (
+ axisLabel.value <= chartEnd
+ ))
}
/**
* Calculates a bar height for a histogram count.
*
- * Used by MemberRatingInfoModal to preserve visible bars for low non-zero ranges.
+ * Empty buckets still get a short stub so the chart baseline stays visible,
+ * especially at the low end of the distribution (0 to the first populated range).
+ * Populated buckets use a slightly taller minimum so small counts remain readable.
*
* @param {number} value - Number of members in the rating range.
* @param {number} maxValue - Highest count in the distribution.
* @returns {number} A percentage height for CSS rendering.
*/
const getBarHeight = (value: number, maxValue: number): number => {
- if (value <= 0 || maxValue <= 0) {
+ if (maxValue <= 0) {
return 0
}
+ if (value <= 0) {
+ return 1
+ }
+
return Math.max(4, Math.round((value / maxValue) * 100))
}
@@ -268,15 +371,20 @@ const MemberRatingInfoModal: FC = (props: MemberRati
const ratingColor: string = getRatingColor(props.rating)
const selectedRatingTier: RatingTier = getRatingTier(props.rating)
const distributionRanges: RatingDistributionRange[] = useMemo(() => (
- getDistributionRanges(props.ratingDistribution?.distribution)
+ getVisibleDistributionRanges(
+ getDistributionRanges(props.ratingDistribution?.distribution),
+ )
), [props.ratingDistribution])
const maxDistributionValue: number = Math.max(
1,
...distributionRanges.map((range: RatingDistributionRange) => range.value),
)
const markerPosition: number = props.rating !== undefined
- ? getChartPosition(props.rating, distributionRanges)
+ ? getMarkerPosition(props.rating, distributionRanges)
: 0
+ const visibleAxisLabels: Array<{ label: string, value: number }> = useMemo(() => (
+ getVisibleAxisLabels(distributionRanges)
+ ), [distributionRanges])
const shouldStackMarkerRating: boolean = props.rating !== undefined && (
markerPosition >= stackedMarkerPositionThreshold
|| props.rating >= stackedMarkerRatingThreshold
@@ -394,10 +502,15 @@ const MemberRatingInfoModal: FC = (props: MemberRati
)}
- {chartAxisLabels.map((axisLabel: { label: string, value: number }) => (
+ {visibleAxisLabels.map((axisLabel: { label: string, value: number }) => (
{axisLabel.label}
diff --git a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/ModifyPreferredRolesModal.tsx b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/ModifyPreferredRolesModal.tsx
index edbefeffb..8f0c85e81 100644
--- a/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/ModifyPreferredRolesModal.tsx
+++ b/src/apps/profiles/src/member-profile/about-me/MemberRatingCard/ModifyPreferredRolesModal/ModifyPreferredRolesModal.tsx
@@ -134,6 +134,7 @@ const ModifyPreferredRolesModal: FC = (props: Mo
label='Preferred Roles'
name='preferredRoles'
onFetchOptions={fetchPreferredRoles}
+ openMenuOnClick
options={preferredRoleOptions}
onChange={handlePreferredRolesChange}
placeholder='Select preferred roles'
diff --git a/src/apps/reports/src/pages/dashboards/DashboardChart.spec.tsx b/src/apps/reports/src/pages/dashboards/DashboardChart.spec.tsx
index bc9eb3227..5d26cade2 100644
--- a/src/apps/reports/src/pages/dashboards/DashboardChart.spec.tsx
+++ b/src/apps/reports/src/pages/dashboards/DashboardChart.spec.tsx
@@ -8,21 +8,23 @@ import {
import Highcharts from 'highcharts'
import {
+ ChallengeParticipationDashboard,
MemberPaymentByCustomerDashboard,
NewSignupsDashboard,
} from '../../lib/services'
import { DashboardChart } from './DashboardChart'
+let chartOptions: Highcharts.Options
+
jest.mock('highcharts-react-official', () => ({
__esModule: true,
- default: (props: {
- options: {
- series?: Array<{ data?: number[]; name?: string }>
- tooltip?: { pointFormat?: string }
- }
- }): JSX.Element => {
- const series = props.options.series || []
+ default: (props: { options: Highcharts.Options }): JSX.Element => {
+ chartOptions = props.options
+ const series = props.options.series as Array<{
+ data?: number[]
+ name?: string
+ }> || []
return (
({
data-series-names={series.map(item => item.name)
.join('|')}
data-testid='dashboard-chart'
- data-tooltip={props.options.tooltip?.pointFormat}
/>
)
},
@@ -96,10 +97,61 @@ const signupResponse: NewSignupsDashboard = {
},
}
+const challengeParticipationResponse: ChallengeParticipationDashboard = {
+ dashboard: 'challenge-participation',
+ endDate: '2026-02-01T00:00:00.000Z',
+ months: [{
+ month: '2026-01-01',
+ registrants: 120,
+ submitters: 75,
+ }],
+ startDate: '2026-01-01T00:00:00.000Z',
+ summary: {
+ peakMonth: '2026-01-01',
+ peakMonthRegistrants: 120,
+ submissionRate: 62.5,
+ totalUniqueRegistrants: 120,
+ totalUniqueSubmitters: 75,
+ },
+}
const pointValueToken = '{point.y:,.0f}'
const countTooltipValue = ` ${pointValueToken}`
const currencyTooltipValue = ` $${pointValueToken}`
+/**
+ * Invokes the tooltip formatter captured from the rendered chart.
+ *
+ * Tests use this helper to verify totals independently of Highcharts' DOM renderer.
+ *
+ * @param values Hovered series values for one month.
+ * @returns The formatter's tooltip HTML as one string.
+ * @throws Error when the chart has no formatter or it returns no HTML.
+ */
+function formatTooltip(values: number[]): string {
+ const formatter = chartOptions.tooltip?.formatter
+ if (!formatter) {
+ throw new Error('Tooltip formatter is missing')
+ }
+
+ const context = {
+ points: values.map(y => ({ y })),
+ } as unknown as Highcharts.TooltipFormatterContextObject
+ const tooltip = {
+ defaultFormatter: () => [' Jan ’26', 'series rows'],
+ } as unknown as Highcharts.Tooltip
+ const result = formatter.call(context, tooltip)
+
+ if (typeof result === 'string') {
+ return result
+ }
+
+ if (Array.isArray(result)) {
+ return result.join('')
+ }
+
+ throw new Error('Tooltip formatter did not return HTML')
+}
+
describe('DashboardChart', () => {
it('formats tooltip thousands with commas', () => {
expect(Highcharts.getOptions().lang?.thousandsSep)
@@ -126,8 +178,10 @@ describe('DashboardChart', () => {
'data-series-data',
'[[125000,140000],[80000,0],[20000,25000]]',
)
- expect(chart.getAttribute('data-tooltip'))
+ expect(chartOptions.tooltip?.pointFormat)
.toContain(currencyTooltipValue)
+ expect(formatTooltip([125_000, 80_000, 20_000]))
+ .toContain('Total: $225,000')
expect(within(table)
.getByRole('columnheader', { name: 'Customer A' }))
.toBeInTheDocument()
@@ -142,20 +196,30 @@ describe('DashboardChart', () => {
.toBeInTheDocument()
})
- it('keeps existing count dashboards unit-free', () => {
+ it('keeps count dashboard tooltips unit-free and adds their total', () => {
render( )
- const chart = screen.getByTestId('dashboard-chart')
const table = screen.getByRole('table', {
name: 'New Signups by Month monthly data',
})
- expect(chart.getAttribute('data-tooltip'))
+ expect(chartOptions.tooltip?.pointFormat)
.toContain(countTooltipValue)
- expect(chart.getAttribute('data-tooltip'))
+ expect(chartOptions.tooltip?.pointFormat)
.not.toContain(currencyTooltipValue)
+ expect(formatTooltip([90, 10]))
+ .toContain('Total: 100')
+ expect(formatTooltip([90, 10]))
+ .not.toContain('$100')
expect(within(table)
.getByRole('cell', { name: '90' }))
.toBeInTheDocument()
})
+
+ it('adds a total to grouped report tooltips', () => {
+ render( )
+
+ expect(formatTooltip([120, 75]))
+ .toContain('Total: 195')
+ })
})
diff --git a/src/apps/reports/src/pages/dashboards/DashboardChart.tsx b/src/apps/reports/src/pages/dashboards/DashboardChart.tsx
index 54182e43a..eeeab09db 100644
--- a/src/apps/reports/src/pages/dashboards/DashboardChart.tsx
+++ b/src/apps/reports/src/pages/dashboards/DashboardChart.tsx
@@ -50,7 +50,7 @@ function getSeriesValue(month: DashboardMonth, key: string): number {
*
* @param props Dashboard response and compact-card presentation flag.
* @returns A stacked or grouped column chart with month categories along the
- * bottom axis and an accessible monthly data table.
+ * bottom axis, monthly tooltip totals, and an accessible monthly data table.
* @throws Does not throw. Invalid or absent point values are rendered as zero.
*/
export const DashboardChart: FC = props => {
@@ -119,6 +119,29 @@ export const DashboardChart: FC = props => {
text: undefined,
},
tooltip: {
+ /**
+ * Appends the hovered month's total to Highcharts' shared tooltip.
+ *
+ * Highcharts invokes this callback for card and detail charts.
+ *
+ * @param tooltip Highcharts tooltip used to render the existing rows.
+ * @returns The default tooltip content followed by the formatted total.
+ * @throws Does not throw for the normalized chart-series values.
+ */
+ formatter(tooltip: Highcharts.Tooltip) {
+ // Highcharts supplies the shared tooltip context through `this`.
+ // eslint-disable-next-line react/no-this-in-sfc
+ const points = this.points || [this]
+ const total = points.reduce((sum, point) => sum + (point.y ?? 0), 0)
+ // eslint-disable-next-line react/no-this-in-sfc
+ const content = tooltip.defaultFormatter.call(this, tooltip)
+ const totalRow = `Total: ${isCurrency ? '$' : ''}`
+ + `${Highcharts.numberFormat(total, 0)}`
+
+ return Array.isArray(content)
+ ? [...content, totalRow]
+ : `${content}${totalRow}`
+ },
headerFormat: '{point.key} ',
pointFormat: '● '
+ `{series.name}: ${isCurrency ? '$' : ''}{point.y:,.0f} `,
diff --git a/src/apps/reports/src/pages/reports/ReportsPage.spec.tsx b/src/apps/reports/src/pages/reports/ReportsPage.spec.tsx
index 05dbbfec6..d2f9fd5c7 100644
--- a/src/apps/reports/src/pages/reports/ReportsPage.spec.tsx
+++ b/src/apps/reports/src/pages/reports/ReportsPage.spec.tsx
@@ -17,9 +17,13 @@ import {
useLocation,
} from 'react-router-dom'
-import { fetchReportsIndex } from '../../lib/services'
+import {
+ fetchReportJson,
+ fetchReportsIndex,
+ SfdcBillingAccountPaymentRow,
+} from '../../lib/services'
-import { ReportsPage } from './ReportsPage'
+import { BillingAccountsPage, ReportsPage } from './ReportsPage'
type MockSelectProps = {
disabled?: boolean
@@ -86,6 +90,8 @@ jest.mock('../../lib/utils', () => ({
}))
const mockedFetchReportsIndex = fetchReportsIndex as jest.Mock
+const mockedFetchReportJson = fetchReportJson as jest.Mock
+const originalTimezone = process.env.TZ
const LocationProbe = (): JSX.Element => {
const { pathname }: { pathname: string } = useLocation()
@@ -109,6 +115,15 @@ describe('Reports page navigation', () => {
})
})
+ afterEach(() => {
+ if (originalTimezone) {
+ process.env.TZ = originalTimezone
+ return
+ }
+
+ delete process.env.TZ
+ })
+
it('opens Bulk Member Lookup from the Reports app root', async () => {
render(
@@ -137,4 +152,64 @@ describe('Reports page navigation', () => {
expect(screen.getByTestId('location'))
.toHaveTextContent('/reports/bulk-member-lookup')
})
+
+ it('renders SFDC payment dates in America/New_York', async () => {
+ process.env.TZ = 'Asia/Colombo'
+ const paymentDate = '2026-07-31T18:53:33.383-04:00'
+ const payment: SfdcBillingAccountPaymentRow = {
+ billingAccountId: '80000001',
+ category: 'CHALLENGE_PAYMENT',
+ challengeFee: '0.00',
+ challengeId: 'challenge-id',
+ challengeName: 'July payment',
+ challengeStatus: 'Completed',
+ isTask: false,
+ paymentAmount: '100.00',
+ paymentDate,
+ paymentId: 'payment-id',
+ paymentStatus: 'PAID',
+ winnerFirstName: 'Ada',
+ winnerHandle: 'ada',
+ winnerId: '123',
+ winnerLastName: 'Lovelace',
+ }
+ mockedFetchReportJson.mockResolvedValue([payment])
+
+ render(
+
+
+ ,
+ )
+
+ expect(await screen.findByText('July payment'))
+ .toBeInTheDocument()
+ const parsedPaymentDate = new Date(paymentDate)
+ const displayedDate = parsedPaymentDate
+ .toLocaleString(undefined, { timeZone: 'America/New_York' })
+ const browserDisplayedDate = parsedPaymentDate
+ .toLocaleString()
+
+ expect(screen.getByText(displayedDate))
+ .toBeInTheDocument()
+ expect(parsedPaymentDate.getUTCDate())
+ .toBe(31)
+ expect(parsedPaymentDate.getDate())
+ .toBe(1)
+ expect(displayedDate)
+ .not.toBe(browserDisplayedDate)
+
+ mockedFetchReportJson.mockResolvedValueOnce({
+ billingAccount: {
+ budget: '1000.00',
+ markup: '0.00',
+ name: 'Boundary account',
+ startDate: paymentDate,
+ status: 'Active',
+ },
+ })
+ fireEvent.click(screen.getByRole('button', { name: '80000001' }))
+
+ expect(await screen.findByText(browserDisplayedDate))
+ .toBeInTheDocument()
+ })
})
diff --git a/src/apps/reports/src/pages/reports/ReportsPage.tsx b/src/apps/reports/src/pages/reports/ReportsPage.tsx
index eeeecefea..8322afbde 100644
--- a/src/apps/reports/src/pages/reports/ReportsPage.tsx
+++ b/src/apps/reports/src/pages/reports/ReportsPage.tsx
@@ -178,6 +178,22 @@ const formatPaymentDate = (iso: string): string => {
.toLocaleString()
}
+/**
+ * Formats an SFDC payment timestamp in the report's America/New_York timezone.
+ * The input is an ISO date-time string and the returned value is used in payment table cells.
+ * Invalid inputs are returned unchanged, so this function does not throw parsing errors.
+ */
+const formatSfdcPaymentDate = (iso: string): string => {
+ const parsed = Date.parse(iso)
+
+ if (Number.isNaN(parsed)) {
+ return iso
+ }
+
+ return new Date(parsed)
+ .toLocaleString(undefined, { timeZone: 'America/New_York' })
+}
+
const PAYMENT_TABLE_COLUMNS: { key: keyof SfdcBillingAccountPaymentRow; label: string }[] = [
{ key: 'paymentId', label: 'Payment ID' },
{ key: 'paymentDate', label: 'Payment date' },
@@ -431,7 +447,7 @@ const BillingAccountReportResults = (
const value = row[colKey]
if (colKey === 'paymentDate') {
- return formatPaymentDate(String(value))
+ return formatSfdcPaymentDate(String(value))
}
if (colKey === 'billingAccountId') {
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.spec.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.spec.tsx
index 665c3ec19..f9e4385db 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.spec.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.spec.tsx
@@ -15,6 +15,7 @@ import { TabContentReview } from './TabContentReview'
const mockUseRole = jest.fn()
const mockTableAppealsForSubmitter = jest.fn()
const mockTableAppealsResponse = jest.fn()
+const mockTableReview = jest.fn()
const mockTableReviewForSubmitter = jest.fn()
jest.mock('~/config', () => ({
@@ -78,7 +79,15 @@ jest.mock('../TableNoRecord', () => ({
}))
jest.mock('../TableReview', () => ({
- TableReview: () => Reviewer reviews ,
+ TableReview: (props: { datas: SubmissionInfo[] }) => {
+ mockTableReview(props)
+ return (
+
+ {props.datas.map(submission => submission.id)
+ .join(',')}
+
+ )
+ },
}))
jest.mock('../TableReviewForSubmitter', () => ({
@@ -208,4 +217,63 @@ describe('TabContentReview submitter Appeals ownership', () => {
],
}))
})
+
+ it('passes both finite-limit Design reviews for one member to the reviewer table', () => {
+ const olderSubmission = {
+ id: 'member-submission-older',
+ isLatest: false,
+ memberId: 'member-shared',
+ review: {
+ phaseName: 'Review',
+ reviewType: 'Review',
+ },
+ submittedDate: '2026-08-12T10:00:00Z',
+ type: 'CONTEST_SUBMISSION',
+ } as SubmissionInfo
+ const latestSubmission = {
+ ...olderSubmission,
+ id: 'member-submission-latest',
+ isLatest: true,
+ submittedDate: '2026-08-12T11:00:00Z',
+ }
+ const reviewerChallengeInfo = {
+ ...challengeInfo,
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({ count: '2', limit: 'true', unlimited: 'false' }),
+ }],
+ submissions: [olderSubmission, latestSubmission],
+ track: {
+ id: 'design-track',
+ name: 'Design',
+ },
+ } as ChallengeInfo
+ const reviewerContext = {
+ ...challengeContext,
+ challengeInfo: reviewerChallengeInfo,
+ myResources: [],
+ myRoles: ['Reviewer'],
+ } as unknown as ChallengeDetailContextModel
+ mockUseRole.mockReturnValue({
+ actionChallengeRole: 'Reviewer',
+ hasApproverRole: false,
+ isPrivilegedRole: true,
+ })
+
+ render(
+
+
+ ,
+ )
+
+ expect(mockTableReview)
+ .toHaveBeenLastCalledWith(expect.objectContaining({
+ datas: [olderSubmission, latestSubmission],
+ }))
+ })
})
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx
index 41e5ae051..46bbebb96 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentReview.tsx
@@ -770,23 +770,23 @@ export const TabContentReview: FC = (props: Props) => {
)
const reviewerRowsForReviewTab = useMemo(
() => (shouldSortReviewTabByScore
- ? sortSubmissionsByReviewScoreDesc(filteredReviews, useAggregateReviewScore)
- : filteredReviews),
- [filteredReviews, shouldSortReviewTabByScore, useAggregateReviewScore],
+ ? sortSubmissionsByReviewScoreDesc(resolvedReviewsWithSubmitter, useAggregateReviewScore)
+ : resolvedReviewsWithSubmitter),
+ [resolvedReviewsWithSubmitter, shouldSortReviewTabByScore, useAggregateReviewScore],
)
const submitterRowsForReviewTab = useMemo(
() => (shouldSortReviewTabByScore
- ? sortSubmissionsByReviewScoreDesc(filteredSubmitterReviews, useAggregateReviewScore)
- : filteredSubmitterReviews),
- [filteredSubmitterReviews, shouldSortReviewTabByScore, useAggregateReviewScore],
+ ? sortSubmissionsByReviewScoreDesc(resolvedSubmitterReviews, useAggregateReviewScore)
+ : resolvedSubmitterReviews),
+ [resolvedSubmitterReviews, shouldSortReviewTabByScore, useAggregateReviewScore],
)
const hideHandleColumn = props.isActiveChallenge
&& actionChallengeRole === REVIEWER
// show loading ui when fetching data
const reviewRows = isSubmitterView
- ? (shouldSortReviewTabByScore ? submitterRowsForReviewTab : filteredSubmitterReviews)
- : (shouldSortReviewTabByScore ? reviewerRowsForReviewTab : filteredReviews)
+ ? submitterRowsForReviewTab
+ : reviewerRowsForReviewTab
if (props.isLoadingReview) {
return
diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx
index 536480f4e..657488568 100644
--- a/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx
+++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/TabContentSubmissions.tsx
@@ -204,7 +204,8 @@ export const TabContentSubmissions: FC = props => {
return
}
- const key = getSubmissionHistoryKey(memberId, submissionId)
+ const submissionType = submissionInfoById.get(submissionId)?.type
+ const key = getSubmissionHistoryKey(memberId, submissionId, submissionType)
const entries = historyByMember.get(key) ?? []
if (!entries.length) {
return
@@ -212,7 +213,7 @@ export const TabContentSubmissions: FC = props => {
setHistoryKey(key)
},
- [historyByMember],
+ [historyByMember, submissionInfoById],
)
const handleHistoryButtonClick = useCallback(
@@ -524,7 +525,11 @@ export const TabContentSubmissions: FC = props => {
return -
}
- const key = getSubmissionHistoryKey(submission.memberId, submission.id)
+ const key = getSubmissionHistoryKey(
+ submission.memberId,
+ submission.id,
+ submission.type,
+ )
const historyEntries = historyByMember.get(key) ?? []
if (!historyEntries.length) {
return -
diff --git a/src/apps/review/src/lib/components/TableReview/TableReview.tsx b/src/apps/review/src/lib/components/TableReview/TableReview.tsx
index fec6728bc..a06faab00 100644
--- a/src/apps/review/src/lib/components/TableReview/TableReview.tsx
+++ b/src/apps/review/src/lib/components/TableReview/TableReview.tsx
@@ -41,7 +41,7 @@ import {
} from '../../models'
import {
aggregateSubmissionReviews,
- challengeHasSubmissionLimit,
+ getChallengeSubmissionSelectionLimit,
isMarathonMatchChallenge,
isReviewPhase,
isReviewPhaseCurrentlyOpen,
@@ -180,6 +180,11 @@ export const TableReview: FC = (props: TableReviewProps) => {
[challengeInfo, submissionTypes],
)
+ const submissionSelectionLimit = useMemo(
+ () => getChallengeSubmissionSelectionLimit(challengeInfo),
+ [challengeInfo],
+ )
+
const {
closeHistoryModal,
historyByMember,
@@ -193,11 +198,12 @@ export const TableReview: FC = (props: TableReviewProps) => {
datas: reviewPhaseDatas,
filteredAll: filteredChallengeSubmissions,
isSubmissionTab: true,
+ maxVisibleSubmissions: submissionSelectionLimit,
})
const restrictToLatest = useMemo(
- () => challengeHasSubmissionLimit(challengeInfo),
- [challengeInfo],
+ () => submissionSelectionLimit !== undefined,
+ [submissionSelectionLimit],
)
const useAggregateReviewScore = useMemo(
() => isMarathonMatchChallenge(challengeInfo),
@@ -650,7 +656,11 @@ export const TableReview: FC = (props: TableReviewProps) => {
)
}
- const historyKeyForRow = getSubmissionHistoryKey(submission.memberId, submission.id)
+ const historyKeyForRow = getSubmissionHistoryKey(
+ submission.memberId,
+ submission.id,
+ submission.type,
+ )
const rowHistory = historyByMember.get(historyKeyForRow) ?? []
const buildHistoryAction = (): JSX.Element | undefined => {
diff --git a/src/apps/review/src/lib/components/TableReviewForSubmitter/TableReviewForSubmitter.tsx b/src/apps/review/src/lib/components/TableReviewForSubmitter/TableReviewForSubmitter.tsx
index bd6aad00d..cfe24785b 100644
--- a/src/apps/review/src/lib/components/TableReviewForSubmitter/TableReviewForSubmitter.tsx
+++ b/src/apps/review/src/lib/components/TableReviewForSubmitter/TableReviewForSubmitter.tsx
@@ -40,7 +40,7 @@ import type {
} from '../common/types'
import {
aggregateSubmissionReviews,
- challengeHasSubmissionLimit,
+ getChallengeSubmissionSelectionLimit,
getSubmissionHistoryKey,
isAppealsPhase,
isAppealsResponsePhase,
@@ -155,6 +155,11 @@ export const TableReviewForSubmitter: FC = (props:
[challengeInfo?.submissions, datas, submissionTypes],
)
+ const submissionSelectionLimit = useMemo(
+ () => getChallengeSubmissionSelectionLimit(challengeInfo),
+ [challengeInfo],
+ )
+
const {
closeHistoryModal,
historyByMember,
@@ -168,11 +173,12 @@ export const TableReviewForSubmitter: FC = (props:
datas,
filteredAll,
isSubmissionTab: true,
+ maxVisibleSubmissions: submissionSelectionLimit,
})
const restrictToLatest = useMemo(
- () => challengeHasSubmissionLimit(challengeInfo),
- [challengeInfo],
+ () => submissionSelectionLimit !== undefined,
+ [submissionSelectionLimit],
)
const useAggregateReviewScore = useMemo(
() => isMarathonMatchChallenge(challengeInfo),
@@ -524,6 +530,7 @@ export const TableReviewForSubmitter: FC = (props:
const historyKeyForSubmission = getSubmissionHistoryKey(
submission.memberId,
submission.id,
+ submission.type,
)
const historyEntries = historyByMember.get(historyKeyForSubmission) ?? []
const filteredHistory = restrictToLatest
diff --git a/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx b/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx
index 62f4617fd..512ce4dee 100644
--- a/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx
+++ b/src/apps/review/src/lib/components/TableSubmissionScreening/TableSubmissionScreening.tsx
@@ -33,7 +33,7 @@ import {
import { TableWrapper } from '../TableWrapper'
import { SubmissionHistoryModal } from '../SubmissionHistoryModal'
import {
- challengeHasSubmissionLimit,
+ getChallengeSubmissionSelectionLimit,
getHandleUrl,
getSubmissionHistoryKey,
isReviewPhaseCurrentlyOpen,
@@ -400,7 +400,11 @@ const createHistoryAction = ({
return undefined
}
- const historyKeyForRow = getSubmissionHistoryKey(data.memberId, data.submissionId)
+ const historyKeyForRow = getSubmissionHistoryKey(
+ data.memberId,
+ data.submissionId,
+ data.type,
+ )
const historyEntries = historyByMember.get(historyKeyForRow) ?? []
if (!historyEntries.length) {
return undefined
@@ -945,9 +949,16 @@ export const TableSubmissionScreening: FC = (props: Props) => {
[submissionMetaById],
)
+ const submissionSelectionLimit = useMemo(
+ () => getChallengeSubmissionSelectionLimit(challengeInfo),
+ [challengeInfo],
+ )
+
const submissionHistory = useMemo(
- () => partitionSubmissionHistory(primarySubmissionInfos, historySourceSubmissions),
- [historySourceSubmissions, primarySubmissionInfos],
+ () => partitionSubmissionHistory(primarySubmissionInfos, historySourceSubmissions, {
+ visibleSubmissionCount: submissionSelectionLimit,
+ }),
+ [historySourceSubmissions, primarySubmissionInfos, submissionSelectionLimit],
)
const { historyByMember, latestSubmissionIds }: SubmissionHistoryPartition = submissionHistory
@@ -957,16 +968,14 @@ export const TableSubmissionScreening: FC = (props: Props) => {
rows: visibleScreenings,
}: ScreeningRowsSelection = useMemo(
() => selectVisibleScreeningRows({
- hasSubmissionLimit: challengeHasSubmissionLimit(challengeInfo),
latestSubmissionIds,
screeningRows: props.screenings,
- submissionInfos: primarySubmissionInfos,
+ submissionLimit: submissionSelectionLimit,
}),
[
- challengeInfo,
latestSubmissionIds,
- primarySubmissionInfos,
props.screenings,
+ submissionSelectionLimit,
],
)
@@ -1051,7 +1060,8 @@ export const TableSubmissionScreening: FC = (props: Props) => {
const openHistoryModal = useCallback(
(memberId: string | undefined, submissionId: string): void => {
- const key = getSubmissionHistoryKey(memberId, submissionId)
+ const submissionType = submissionMetaById.get(submissionId)?.type
+ const key = getSubmissionHistoryKey(memberId, submissionId, submissionType)
const historyEntries = historyByMember.get(key)
if (!historyEntries || historyEntries.length === 0) {
return
@@ -1059,7 +1069,7 @@ export const TableSubmissionScreening: FC = (props: Props) => {
setHistoryKey(key)
},
- [historyByMember],
+ [historyByMember, submissionMetaById],
)
const openReopenDialog = useCallback(
diff --git a/src/apps/review/src/lib/hooks/useSubmissionHistory.ts b/src/apps/review/src/lib/hooks/useSubmissionHistory.ts
index 649265df0..e318f039d 100644
--- a/src/apps/review/src/lib/hooks/useSubmissionHistory.ts
+++ b/src/apps/review/src/lib/hooks/useSubmissionHistory.ts
@@ -9,9 +9,14 @@ import {
import type { SubmissionHistoryPartition } from '../utils/submissionHistory'
interface UseSubmissionHistoryParams {
+ /** Primary table submissions, including review or screening details. */
datas: SubmissionInfo[]
+ /** Complete matching challenge history used to rank submissions. */
filteredAll: SubmissionInfo[]
+ /** Whether the consuming table supports submission-history actions. */
isSubmissionTab: boolean
+ /** Positive latest-submission count per member/type group. Defaults to one. */
+ maxVisibleSubmissions?: number
}
export interface UseSubmissionHistoryResult {
@@ -26,16 +31,23 @@ export interface UseSubmissionHistoryResult {
}
/**
- * Encapsulates submission history modal state and derived metadata for tables.
+ * Encapsulate submission-history ranking and modal state for Review tables.
+ *
+ * @param params - Primary rows, complete matching history, table mode, and visible count.
+ * @returns Latest selected rows and IDs, older member/type history, and modal callbacks.
+ * @throws Does not throw; invalid visible counts are normalized by the partition utility.
*/
export function useSubmissionHistory({
datas,
filteredAll,
isSubmissionTab,
+ maxVisibleSubmissions,
}: UseSubmissionHistoryParams): UseSubmissionHistoryResult {
const submissionHistory = useMemo(
- () => partitionSubmissionHistory(datas, filteredAll),
- [datas, filteredAll],
+ () => partitionSubmissionHistory(datas, filteredAll, {
+ visibleSubmissionCount: maxVisibleSubmissions,
+ }),
+ [datas, filteredAll, maxVisibleSubmissions],
)
const {
@@ -58,7 +70,9 @@ export function useSubmissionHistory({
const openHistoryModal: (memberId: string | undefined, submissionId: string) => void = useCallback(
(memberId: string | undefined, submissionId: string): void => {
- const key = getSubmissionHistoryKey(memberId, submissionId)
+ const submissionType = datas.find(submission => submission.id === submissionId)?.type
+ ?? filteredAll.find(submission => submission.id === submissionId)?.type
+ const key = getSubmissionHistoryKey(memberId, submissionId, submissionType)
const entries = historyByMember.get(key)
if (!entries || entries.length === 0) {
return
@@ -66,7 +80,7 @@ export function useSubmissionHistory({
setHistoryKey(key)
},
- [historyByMember],
+ [datas, filteredAll, historyByMember],
)
const closeHistoryModal = useCallback((): void => {
diff --git a/src/apps/review/src/lib/utils/challenge.spec.ts b/src/apps/review/src/lib/utils/challenge.spec.ts
index b6c48380a..39f52fb24 100644
--- a/src/apps/review/src/lib/utils/challenge.spec.ts
+++ b/src/apps/review/src/lib/utils/challenge.spec.ts
@@ -4,6 +4,7 @@ import {
buildPhaseTabs,
collectReopenEligiblePhaseIds,
findPhaseByTabLabel,
+ getChallengeSubmissionSelectionLimit,
hasPendingApprovalReview,
isFirst2FinishChallenge,
isMarathonMatchChallenge,
@@ -51,6 +52,115 @@ const createBackendPhase = (
})
describe('challenge phase tab helpers', () => {
+ it('uses the configured Design submission count', () => {
+ expect(getChallengeSubmissionSelectionLimit({
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({ count: '2', limit: 'true', unlimited: 'false' }),
+ }],
+ track: {
+ id: 'design-track',
+ name: 'Design',
+ },
+ }))
+ .toBe(2)
+ })
+
+ it('retains the latest-one policy for a finite Design count of one', () => {
+ expect(getChallengeSubmissionSelectionLimit({
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({ count: 1, limit: true, unlimited: false }),
+ }],
+ track: {
+ id: 'design-track',
+ name: 'Design',
+ },
+ }))
+ .toBe(1)
+ })
+
+ it('keeps explicit and default Design submission limits unlimited', () => {
+ const track = {
+ id: 'design-track',
+ name: 'Design',
+ }
+
+ expect(getChallengeSubmissionSelectionLimit({ metadata: [], track }))
+ .toBeUndefined()
+ expect(getChallengeSubmissionSelectionLimit({
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({ count: '', limit: 'false', unlimited: 'true' }),
+ }],
+ track,
+ }))
+ .toBeUndefined()
+ })
+
+ it('recognizes Design from canonical track fields and keeps non-Design latest-one', () => {
+ expect(getChallengeSubmissionSelectionLimit({
+ metadata: [],
+ track: {
+ id: 'design-track',
+ name: '',
+ track: 'DESIGN',
+ },
+ }))
+ .toBeUndefined()
+ expect(getChallengeSubmissionSelectionLimit({
+ metadata: [{
+ name: 'submissionLimit',
+ value: JSON.stringify({ count: '', limit: 'false', unlimited: 'true' }),
+ }],
+ track: {
+ id: 'development-track',
+ name: 'Development',
+ },
+ }))
+ .toBe(1)
+ })
+
+ it.each([
+ [
+ 'an explicit unlimited flag with a stale count',
+ { count: '5', limit: 'false' },
+ undefined,
+ ],
+ [
+ 'contradictory flags',
+ { count: '2', limit: 'true', unlimited: 'true' },
+ 1,
+ ],
+ [
+ 'a non-integer count',
+ '2.5',
+ 1,
+ ],
+ [
+ 'an invalid higher-priority count alias',
+ { count: 'invalid', maximum: '4' },
+ 1,
+ ],
+ [
+ 'an unrecognized boolean flag alias with a valid count',
+ { count: '3', limit: 'unlimited' },
+ 3,
+ ],
+ ])('matches the backend policy for %s', (_description, value, expected) => {
+ expect(getChallengeSubmissionSelectionLimit({
+ metadata: [{
+ name: 'submissionLimit',
+ value: typeof value === 'string' ? value : JSON.stringify(value),
+ }],
+ track: {
+ id: 'design-track',
+ name: 'Design',
+ },
+ }))
+ .toBe(expected)
+ })
+
it('recognizes Marathon Match challenges from type metadata', () => {
expect(isMarathonMatchChallenge({
type: {
diff --git a/src/apps/review/src/lib/utils/challenge.ts b/src/apps/review/src/lib/utils/challenge.ts
index df58c44b9..31dceb290 100644
--- a/src/apps/review/src/lib/utils/challenge.ts
+++ b/src/apps/review/src/lib/utils/challenge.ts
@@ -54,6 +54,13 @@ export function isAppealsResponsePhase(challengeInfo?: ChallengeInfo): boolean {
}
const SUBMISSION_LIMIT_KEY = 'submissionlimit'
+const SUBMISSION_LIMIT_COUNT_FIELDS = [
+ 'count',
+ 'max',
+ 'maximum',
+ 'limitCount',
+ 'value',
+] as const
const UNLIMITED_KEYWORDS = ['unlimited', 'false', '0', 'no', 'none']
const TRUE_KEYWORDS = ['true', 'yes', '1']
@@ -87,6 +94,39 @@ function parseBooleanFlag(value: unknown): boolean | undefined {
return undefined
}
+/**
+ * Parse submission-limit object flags using the backend's accepted boolean aliases.
+ *
+ * @param value - Raw `limit` or `unlimited` flag.
+ * @returns The recognized boolean value, or `undefined` for malformed aliases.
+ * @throws Does not throw.
+ */
+function parseSubmissionLimitFlag(value: unknown): boolean | undefined {
+ if (typeof value === 'boolean') {
+ return value
+ }
+
+ if (value === 1 || value === 0) {
+ return value === 1
+ }
+
+ if (typeof value !== 'string') {
+ return undefined
+ }
+
+ const normalized = value.trim()
+ .toLowerCase()
+ if (TRUE_KEYWORDS.includes(normalized)) {
+ return true
+ }
+
+ if (['false', 'no', '0'].includes(normalized)) {
+ return false
+ }
+
+ return undefined
+}
+
function hasPositiveNumeric(value: unknown): boolean {
if (value === undefined || value === null) {
return false
@@ -171,7 +211,16 @@ function evaluateObjectLimit(candidate: Record): boolean {
return true
}
-export function challengeHasSubmissionLimit(challengeInfo?: ChallengeInfo): boolean {
+/**
+ * Determine whether legacy submission-limit metadata represents a finite limit.
+ *
+ * @param challengeInfo - Challenge metadata containing the legacy `submissionLimit` entry.
+ * @returns True for finite or malformed legacy values and false for explicit unlimited values.
+ * @throws Does not throw; missing metadata retains the legacy finite-limit fallback.
+ */
+export function challengeHasSubmissionLimit(
+ challengeInfo?: Pick,
+): boolean {
const rawValue = findSubmissionLimitMetadata(challengeInfo?.metadata)
if (rawValue === undefined || rawValue === null) {
return true
@@ -198,6 +247,104 @@ export function challengeHasSubmissionLimit(challengeInfo?: ChallengeInfo): bool
return true
}
+/**
+ * Convert a numeric metadata value to a safe positive-integer submission count.
+ *
+ * @param value - Raw count value from challenge metadata.
+ * @returns The positive whole-number count, or `undefined` when the value is not positive numeric data.
+ * @throws Does not throw; invalid values are ignored.
+ */
+function parsePositiveSubmissionLimit(value: unknown): number | undefined {
+ if (typeof value !== 'number' && typeof value !== 'string') {
+ return undefined
+ }
+
+ const numericValue = Number(value)
+ return Number.isSafeInteger(numericValue) && numericValue > 0
+ ? numericValue
+ : undefined
+}
+
+/**
+ * Resolve how many submissions per member and exact submission type Review should display.
+ *
+ * Design challenges with missing or explicit unlimited metadata keep every submission visible;
+ * a positive configured count selects that many. Flag conflicts, invalid counts, malformed Design
+ * data, and all non-Design challenges retain the existing latest-one behavior. Explicit unlimited
+ * flags take precedence over stale counts. These rules mirror the backend selection policy so the
+ * UI displays the same submission set for which scorecards were created.
+ *
+ * @param challengeInfo - Challenge track and submission-limit metadata.
+ * @returns A positive latest-submission count, or `undefined` when every submission is visible.
+ * @throws Does not throw; malformed limited Design metadata falls back to one.
+ */
+export function getChallengeSubmissionSelectionLimit(
+ challengeInfo?: Pick,
+): number | undefined {
+ const trackCandidates = [
+ challengeInfo?.track?.name,
+ challengeInfo?.track?.abbreviation,
+ challengeInfo?.track?.track,
+ ]
+ const isDesignChallenge = trackCandidates.some(candidate => (
+ normalizeChallengeKey(candidate) === 'design'
+ ))
+ if (!isDesignChallenge) {
+ return 1
+ }
+
+ const rawValue = findSubmissionLimitMetadata(challengeInfo?.metadata)
+ if (rawValue === undefined || rawValue === null) {
+ return undefined
+ }
+
+ const normalized = normalizeLimitMetadataValue(rawValue)
+ const primitiveLimit = parsePositiveSubmissionLimit(normalized)
+ if (primitiveLimit !== undefined) {
+ return primitiveLimit
+ }
+
+ if (typeof normalized === 'number' && normalized === 0) {
+ return undefined
+ }
+
+ if (typeof normalized === 'boolean') {
+ return normalized ? 1 : undefined
+ }
+
+ if (typeof normalized === 'string') {
+ return UNLIMITED_KEYWORDS.includes(normalized.trim()
+ .toLowerCase())
+ ? undefined
+ : 1
+ }
+
+ if (normalized && typeof normalized === 'object' && !Array.isArray(normalized)) {
+ const candidate = normalized as Record
+ const unlimitedFlag = parseSubmissionLimitFlag(candidate.unlimited)
+ const limitFlag = parseSubmissionLimitFlag(candidate.limit)
+ const countValue = SUBMISSION_LIMIT_COUNT_FIELDS
+ .map(fieldName => candidate[fieldName])
+ .find(value => value !== undefined && value !== null && value !== '')
+ const count = parsePositiveSubmissionLimit(countValue)
+ const flagsConflict = unlimitedFlag !== undefined
+ && limitFlag !== undefined
+ && unlimitedFlag === limitFlag
+
+ if (flagsConflict) {
+ return 1
+ }
+
+ if (unlimitedFlag === true || limitFlag === false) {
+ return undefined
+ }
+
+ return count ?? 1
+ }
+
+ return 1
+}
+
export type PhaseLike = Pick<
BackendPhase,
| 'id'
diff --git a/src/apps/review/src/lib/utils/screeningRows.spec.ts b/src/apps/review/src/lib/utils/screeningRows.spec.ts
index 101982820..1ac1f3c22 100644
--- a/src/apps/review/src/lib/utils/screeningRows.spec.ts
+++ b/src/apps/review/src/lib/utils/screeningRows.spec.ts
@@ -1,47 +1,21 @@
-import type { Screening, SubmissionInfo } from '../models'
+import type { Screening } from '../models'
import { selectVisibleScreeningRows } from './screeningRows'
const screeningRows = [
- { submissionId: 'member-one-old' },
+ { submissionId: 'member-one-oldest' },
+ { submissionId: 'member-one-middle' },
{ submissionId: 'member-one-latest' },
- { submissionId: 'member-two-old' },
+ { submissionId: 'member-two-oldest' },
+ { submissionId: 'member-two-middle' },
{ submissionId: 'member-two-latest' },
] as Screening[]
-const latestSubmissionIds = new Set([
- 'member-one-latest',
- 'member-two-latest',
-])
-
describe('selectVisibleScreeningRows', () => {
- it('retains every row for an unlimited challenge without latest flags', () => {
- const result = selectVisibleScreeningRows({
- hasSubmissionLimit: false,
- latestSubmissionIds,
- screeningRows,
- submissionInfos: [{}, {}, {}, {}],
- })
-
- expect(result.isRestrictedToLatest)
- .toBe(false)
- expect(result.rows)
- .toBe(screeningRows)
- })
-
- it('retains every row for an unlimited challenge with stale latest flags', () => {
- const submissionInfos: Array> = [
- { isLatest: false },
- { isLatest: true },
- { isLatest: false },
- { isLatest: true },
- ]
-
+ it('retains every row for an unlimited challenge', () => {
const result = selectVisibleScreeningRows({
- hasSubmissionLimit: false,
- latestSubmissionIds,
+ latestSubmissionIds: new Set(),
screeningRows,
- submissionInfos,
})
expect(result.isRestrictedToLatest)
@@ -50,41 +24,45 @@ describe('selectVisibleScreeningRows', () => {
.toBe(screeningRows)
})
- it('retains every row for a limited challenge without explicit latest flags', () => {
+ it('retains the latest two selected rows per member for a finite count of two', () => {
const result = selectVisibleScreeningRows({
- hasSubmissionLimit: true,
- latestSubmissionIds,
+ latestSubmissionIds: new Set([
+ 'member-one-middle',
+ 'member-one-latest',
+ 'member-two-middle',
+ 'member-two-latest',
+ ]),
screeningRows,
- submissionInfos: [{}, {}, {}, {}],
+ submissionLimit: 2,
})
expect(result.isRestrictedToLatest)
- .toBe(false)
+ .toBe(true)
expect(result.rows)
- .toBe(screeningRows)
+ .toEqual([
+ screeningRows[1],
+ screeningRows[2],
+ screeningRows[4],
+ screeningRows[5],
+ ])
})
- it('retains only explicit latest submissions for a limited challenge', () => {
- const submissionInfos: Array> = [
- { isLatest: false },
- { isLatest: true },
- { isLatest: false },
- { isLatest: true },
- ]
-
+ it('retains only the selected latest row for a finite count of one', () => {
const result = selectVisibleScreeningRows({
- hasSubmissionLimit: true,
- latestSubmissionIds,
+ latestSubmissionIds: new Set([
+ 'member-one-latest',
+ 'member-two-latest',
+ ]),
screeningRows,
- submissionInfos,
+ submissionLimit: 1,
})
expect(result.isRestrictedToLatest)
.toBe(true)
expect(result.rows)
.toEqual([
- screeningRows[1],
- screeningRows[3],
+ screeningRows[2],
+ screeningRows[5],
])
})
})
diff --git a/src/apps/review/src/lib/utils/screeningRows.ts b/src/apps/review/src/lib/utils/screeningRows.ts
index c47e6a49d..74696073f 100644
--- a/src/apps/review/src/lib/utils/screeningRows.ts
+++ b/src/apps/review/src/lib/utils/screeningRows.ts
@@ -1,6 +1,4 @@
-import type { Screening, SubmissionInfo } from '../models'
-
-import { hasIsLatestFlag } from './submissionHistory'
+import type { Screening } from '../models'
export interface ScreeningRowsSelection {
isRestrictedToLatest: boolean
@@ -8,36 +6,31 @@ export interface ScreeningRowsSelection {
}
export interface SelectVisibleScreeningRowsOptions {
- hasSubmissionLimit: boolean
latestSubmissionIds: ReadonlySet
screeningRows: Screening[]
- submissionInfos: Array>
+ submissionLimit?: number
}
/**
* Select the Screening rows that should be displayed for a challenge.
*
* The Screening table uses this selection for both desktop and mobile views.
- * Limited challenges collapse submission history only when the API supplies
- * explicit `isLatest` flags. Unlimited challenges, or responses without those
- * flags, retain every Screening row. This function performs no I/O and does
- * not throw.
+ * Finite challenges retain the latest configured number of submission IDs selected
+ * independently per member and exact submission type. Unlimited challenges retain
+ * every Screening row. This function performs no I/O and does not throw.
*
* @param options visibility inputs for the challenge and its submissions
- * @param options.hasSubmissionLimit whether the challenge limits submissions
* @param options.latestSubmissionIds latest submission ids calculated per member
* @param options.screeningRows Screening rows available for display
- * @param options.submissionInfos submission metadata containing optional latest flags
+ * @param options.submissionLimit finite latest-submission count, or undefined for all
* @returns the visible rows and whether submission history was collapsed
*/
export function selectVisibleScreeningRows({
- hasSubmissionLimit,
latestSubmissionIds,
screeningRows,
- submissionInfos,
+ submissionLimit,
}: SelectVisibleScreeningRowsOptions): ScreeningRowsSelection {
- const isRestrictedToLatest = hasSubmissionLimit
- && hasIsLatestFlag(submissionInfos)
+ const isRestrictedToLatest = submissionLimit !== undefined
return {
isRestrictedToLatest,
diff --git a/src/apps/review/src/lib/utils/submissionHistory.spec.ts b/src/apps/review/src/lib/utils/submissionHistory.spec.ts
new file mode 100644
index 000000000..6ba74773c
--- /dev/null
+++ b/src/apps/review/src/lib/utils/submissionHistory.spec.ts
@@ -0,0 +1,119 @@
+import type { SubmissionInfo } from '../models'
+
+import {
+ getSubmissionHistoryKey,
+ partitionSubmissionHistory,
+} from './submissionHistory'
+
+/**
+ * Build submission metadata for history-ranking tests.
+ *
+ * @param id - Submission identifier.
+ * @param type - Exact submission type.
+ * @param submittedDate - ISO submission timestamp.
+ * @returns A submission owned by the shared test member.
+ */
+function createSubmission(
+ id: string,
+ type: string,
+ submittedDate: string,
+): SubmissionInfo {
+ return {
+ id,
+ memberId: 'member-one',
+ submittedDate,
+ type,
+ }
+}
+
+const submissions: SubmissionInfo[] = [
+ createSubmission('contest-oldest', 'CONTEST_SUBMISSION', '2026-08-10T10:00:00Z'),
+ createSubmission('contest-middle', 'CONTEST_SUBMISSION', '2026-08-10T11:00:00Z'),
+ createSubmission('contest-newest', 'CONTEST_SUBMISSION', '2026-08-10T12:00:00Z'),
+ createSubmission('checkpoint-oldest', 'CHECKPOINT_SUBMISSION', '2026-08-09T10:00:00Z'),
+ createSubmission('checkpoint-newest', 'CHECKPOINT_SUBMISSION', '2026-08-09T11:00:00Z'),
+]
+
+describe('partitionSubmissionHistory', () => {
+ it('retains the latest two submissions independently for each exact type', () => {
+ const result = partitionSubmissionHistory(submissions, submissions, {
+ visibleSubmissionCount: 2,
+ })
+
+ expect(result.latestSubmissionIds)
+ .toEqual(new Set([
+ 'contest-newest',
+ 'contest-middle',
+ 'checkpoint-newest',
+ 'checkpoint-oldest',
+ ]))
+ expect(result.historyByMember.get(getSubmissionHistoryKey(
+ 'member-one',
+ 'contest-newest',
+ 'CONTEST_SUBMISSION',
+ )))
+ .toEqual([submissions[0]])
+ expect(result.historyByMember.has(getSubmissionHistoryKey(
+ 'member-one',
+ 'checkpoint-newest',
+ 'CHECKPOINT_SUBMISSION',
+ )))
+ .toBe(false)
+ })
+
+ it('defaults finite selection to the latest one per member and type', () => {
+ const result = partitionSubmissionHistory(submissions, submissions)
+
+ expect(result.latestSubmissionIds)
+ .toEqual(new Set([
+ 'contest-newest',
+ 'checkpoint-newest',
+ ]))
+ expect(result.historyByMember.get(getSubmissionHistoryKey(
+ 'member-one',
+ 'contest-newest',
+ 'CONTEST_SUBMISSION',
+ )))
+ .toEqual([
+ submissions[1],
+ submissions[0],
+ ])
+ expect(result.historyByMember.get(getSubmissionHistoryKey(
+ 'member-one',
+ 'checkpoint-newest',
+ 'CHECKPOINT_SUBMISSION',
+ )))
+ .toEqual([submissions[3]])
+ })
+
+ it('ranks complete history before retaining only eligible primary rows', () => {
+ const eligibleOlderSubmission = submissions[1]
+ const completeHistory = [
+ eligibleOlderSubmission,
+ submissions[2],
+ ]
+
+ const latestOne = partitionSubmissionHistory(
+ [eligibleOlderSubmission],
+ completeHistory,
+ { visibleSubmissionCount: 1 },
+ )
+ expect(latestOne.latestSubmissionIds)
+ .toEqual(new Set(['contest-newest']))
+ expect(latestOne.latestSubmissions)
+ .toEqual([])
+
+ const latestTwo = partitionSubmissionHistory(
+ [eligibleOlderSubmission],
+ completeHistory,
+ { visibleSubmissionCount: 2 },
+ )
+ expect(latestTwo.latestSubmissionIds)
+ .toEqual(new Set([
+ 'contest-newest',
+ 'contest-middle',
+ ]))
+ expect(latestTwo.latestSubmissions)
+ .toEqual([eligibleOlderSubmission])
+ })
+})
diff --git a/src/apps/review/src/lib/utils/submissionHistory.ts b/src/apps/review/src/lib/utils/submissionHistory.ts
index a82f29ce7..60ec3fbe9 100644
--- a/src/apps/review/src/lib/utils/submissionHistory.ts
+++ b/src/apps/review/src/lib/utils/submissionHistory.ts
@@ -1,26 +1,74 @@
import { SubmissionInfo } from '../models'
export interface SubmissionHistoryPartition {
- latestSubmissions: SubmissionInfo[]
- latestSubmissionIds: Set
+ /** Older submissions grouped by member and exact normalized submission type. */
historyByMember: Map
+ /** IDs of the latest configured number of submissions in every member/type group. */
+ latestSubmissionIds: Set
+ /** Primary submission rows associated with the latest configured IDs. */
+ latestSubmissions: SubmissionInfo[]
+}
+
+export interface PartitionSubmissionHistoryOptions {
+ /** Positive number of submissions retained in each member/type group. Defaults to one. */
+ visibleSubmissionCount?: number
+}
+
+/**
+ * Normalize the submission type used to isolate contest and checkpoint history.
+ *
+ * @param submissionType - Submission type returned by the API.
+ * @returns A stable normalized type key, including a fallback for missing types.
+ * @throws Does not throw.
+ */
+function normalizeSubmissionHistoryType(submissionType?: string): string {
+ const normalizedType = (submissionType ?? '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]/g, '')
+
+ return normalizedType || '__unknown_type__'
}
+/**
+ * Build the lookup key used for one member's history of an exact submission type.
+ *
+ * @param memberId - Submission owner ID, when available.
+ * @param submissionId - Submission ID used to isolate rows with no owner.
+ * @param submissionType - Exact submission type, normalized for legacy spelling variants.
+ * @returns A stable member/type history key.
+ * @throws Does not throw.
+ */
export function getSubmissionHistoryKey(
memberId: string | undefined,
submissionId: string,
+ submissionType?: string,
): string {
- if (memberId && memberId.length) {
- return memberId
- }
+ const memberKey = memberId && memberId.length
+ ? memberId
+ : `__unknown__::${submissionId}`
- return `__unknown__::${submissionId}`
+ return `${memberKey}::${normalizeSubmissionHistoryType(submissionType)}`
}
+/**
+ * Check whether any submission includes the API's explicit latest flag.
+ *
+ * @param submissions - Submission-like objects to inspect.
+ * @returns True when at least one object includes an `isLatest` value.
+ * @throws Does not throw.
+ */
export function hasIsLatestFlag(submissions: T[]): boolean {
return submissions.some(submission => submission.isLatest !== undefined)
}
+/**
+ * Resolve a submission timestamp for newest-first ordering.
+ *
+ * @param submission - Submission metadata containing raw or formatted dates.
+ * @returns Milliseconds since epoch, or zero when neither date is valid.
+ * @throws Does not throw; invalid dates fall back to zero.
+ */
function getSubmissionTimestamp(submission: SubmissionInfo): number {
const candidates: Array = []
@@ -44,9 +92,40 @@ function getSubmissionTimestamp(submission: SubmissionInfo): number {
return 0
}
+/**
+ * Normalize the requested number of visible submissions per member/type group.
+ *
+ * @param visibleSubmissionCount - Raw configured visible count.
+ * @returns A positive whole-number count, defaulting to one.
+ * @throws Does not throw.
+ */
+function normalizeVisibleSubmissionCount(visibleSubmissionCount?: number): number {
+ if (!Number.isFinite(visibleSubmissionCount) || Number(visibleSubmissionCount) <= 0) {
+ return 1
+ }
+
+ return Math.max(1, Math.floor(Number(visibleSubmissionCount)))
+}
+
+/**
+ * Partition submissions into the latest configured rows and older history.
+ *
+ * Submissions are ranked independently for every member and normalized submission type. Explicit
+ * `isLatest` rows remain first for backward compatibility, followed by submission timestamp. A
+ * duplicated submission ID from the primary and complete-history inputs consumes only one slot.
+ * Only primary rows are returned for display, so ranking cannot reintroduce an ineligible history
+ * entry or promote an older eligible submission into a newer entry's configured slot.
+ *
+ * @param submissions - Primary table submissions.
+ * @param allSubmissions - Optional complete submission history used for ranking and history actions.
+ * @param options - Partition options, including the visible count per member/type group.
+ * @returns Latest submission rows and IDs plus older history grouped by member/type.
+ * @throws Does not throw; invalid visible counts default to one.
+ */
export function partitionSubmissionHistory(
submissions: SubmissionInfo[],
allSubmissions?: SubmissionInfo[],
+ options: PartitionSubmissionHistoryOptions = {},
): SubmissionHistoryPartition {
const byMember = new Map()
const addEntry = (submission: SubmissionInfo | undefined): void => {
@@ -57,6 +136,7 @@ export function partitionSubmissionHistory(
const memberKey = getSubmissionHistoryKey(
submission.memberId,
submission.id,
+ submission.type,
)
const list = byMember.get(memberKey)
if (list) {
@@ -75,56 +155,46 @@ export function partitionSubmissionHistory(
const latestSubmissions: SubmissionInfo[] = []
const latestSubmissionIds = new Set()
const historyByMember = new Map()
- const primaryIds = new Set(primarySubmissions.map(entry => entry.id))
+ const visibleSubmissionCount = normalizeVisibleSubmissionCount(
+ options.visibleSubmissionCount,
+ )
byMember.forEach((entries, memberKey) => {
const sorted = entries
.slice()
- .sort((a, b) => getSubmissionTimestamp(b) - getSubmissionTimestamp(a))
+ .sort((a, b) => {
+ const latestDifference = Number(Boolean(b.isLatest)) - Number(Boolean(a.isLatest))
+ if (latestDifference !== 0) {
+ return latestDifference
+ }
- const flaggedLatest = sorted.filter(entry => entry.isLatest)
- const latestEntry = flaggedLatest.length > 0 ? flaggedLatest[0] : sorted[0]
+ return getSubmissionTimestamp(b) - getSubmissionTimestamp(a)
+ })
+ const seenSubmissionIds = new Set()
+ const uniqueSorted = sorted.filter(entry => {
+ if (!entry.id || seenSubmissionIds.has(entry.id)) {
+ return false
+ }
+
+ seenSubmissionIds.add(entry.id)
+ return true
+ })
+ const visibleEntries = uniqueSorted.slice(0, visibleSubmissionCount)
+ const visibleIdsForGroup = new Set(visibleEntries.map(entry => entry.id))
- if (latestEntry?.id) {
- const latestId = latestEntry.id
+ visibleEntries.forEach(visibleEntry => {
+ const latestId = visibleEntry.id
const matchingPrimary = primarySubmissions.filter(entry => entry.id === latestId)
- if (matchingPrimary.length > 0) {
- matchingPrimary.forEach(entry => {
- latestSubmissions.push(entry)
- })
- } else {
- latestSubmissions.push(latestEntry)
- }
+ matchingPrimary.forEach(entry => {
+ latestSubmissions.push(entry)
+ })
latestSubmissionIds.add(latestId)
- } else if (latestEntry) {
- latestSubmissions.push(latestEntry)
- }
+ })
- const historyEntries = sorted.filter(entry => entry.id !== latestEntry?.id)
+ const historyEntries = uniqueSorted.filter(entry => !visibleIdsForGroup.has(entry.id))
if (historyEntries.length > 0) {
- const seenIds = new Set()
- const uniqueHistory = historyEntries.filter(entry => {
- const key = entry.id
- if (!key) {
- return false
- }
-
- if (primaryIds.has(key) && latestSubmissionIds.has(key)) {
- return false
- }
-
- if (seenIds.has(key)) {
- return false
- }
-
- seenIds.add(key)
- return true
- })
-
- if (uniqueHistory.length > 0) {
- historyByMember.set(memberKey, uniqueHistory)
- }
+ historyByMember.set(memberKey, historyEntries)
}
})
diff --git a/src/apps/support/README.md b/src/apps/support/README.md
index be3c0eff0..ead5236ab 100644
--- a/src/apps/support/README.md
+++ b/src/apps/support/README.md
@@ -3,7 +3,8 @@
The Support subapp lets authenticated Topcoder members open requests, follow
their status, and reply to the Topcoder Support Team. Users with the exact
`Topcoder Support Team` role can see all tickets, assign themselves, search
-closed tickets, reply, and close resolved requests.
+closed tickets, and reply to or close resolved requests assigned to them.
+Closed ticket details identify the Support Team user who closed the request.
## Routes
diff --git a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.module.scss b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.module.scss
index 739979694..f1de3bce5 100644
--- a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.module.scss
+++ b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.module.scss
@@ -1,7 +1,60 @@
+@import '@libs/ui/styles/includes';
+
.markdown {
line-height: 1.55;
overflow-wrap: anywhere;
+ a,
+ a:hover {
+ color: $link-blue-dark;
+ text-decoration: underline;
+ }
+
+ strong {
+ font-weight: $font-weight-bold;
+ }
+
+ em {
+ font-style: italic;
+ }
+
+ h1,
+ h2,
+ h3 {
+ font-weight: $font-weight-bold;
+ margin: $sp-4 0 $sp-2;
+ }
+
+ h1 {
+ font-size: 24px;
+ line-height: 28px;
+ }
+
+ h2 {
+ font-size: 20px;
+ line-height: 24px;
+ }
+
+ h3 {
+ font-size: 18px;
+ line-height: 22px;
+ }
+
+ ol,
+ ul {
+ list-style-position: outside;
+ margin: $sp-3 0;
+ padding-left: $sp-6;
+ }
+
+ ol {
+ list-style-type: decimal;
+ }
+
+ ul {
+ list-style-type: disc;
+ }
+
> :first-child {
margin-top: 0;
}
diff --git a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.spec.tsx b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.spec.tsx
new file mode 100644
index 000000000..79f398813
--- /dev/null
+++ b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.spec.tsx
@@ -0,0 +1,117 @@
+/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
+import '@testing-library/jest-dom'
+import type { ElementType } from 'react'
+import { readFileSync } from 'fs'
+import { render, screen } from '@testing-library/react'
+import remarkBreaks from 'remark-breaks'
+import remarkGfm from 'remark-gfm'
+
+import { MarkdownContent } from './MarkdownContent'
+
+interface MarkdownRendererProps {
+ children: string
+ components: {
+ a: ElementType
+ }
+ remarkPlugins: unknown[]
+ skipHtml: boolean
+}
+
+const mockReactMarkdown = jest.fn()
+
+jest.mock('react-markdown', () => ({
+ __esModule: true,
+ default: (props: MarkdownRendererProps): JSX.Element => {
+ mockReactMarkdown(props)
+ return {props.children}
+ },
+}))
+
+jest.mock('remark-breaks', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}))
+
+jest.mock('remark-gfm', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}))
+
+const markdownStyles = readFileSync(`${__dirname}/MarkdownContent.module.scss`, 'utf8')
+const ticketDetailStyles = readFileSync(
+ `${__dirname}/../../../pages/ticket-details/TicketDetailPage.module.scss`,
+ 'utf8',
+)
+
+describe('MarkdownContent', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('passes rich formatting and uploaded-file Markdown to the safe GFM renderer', () => {
+ const markdown = [
+ '# Request heading',
+ '**Bold text** and *italic text*',
+ '[sample.zip](https://example.test/sample.zip)',
+ '- First item',
+ '1. First step',
+ ].join('\n\n')
+
+ render()
+
+ expect(screen.getByTestId('markdown-source').textContent)
+ .toBe(markdown)
+ expect(mockReactMarkdown)
+ .toHaveBeenCalledWith(expect.objectContaining({
+ children: markdown,
+ remarkPlugins: [remarkGfm, remarkBreaks],
+ skipHtml: true,
+ }))
+ })
+
+ it('opens GFM links in a safe new tab', () => {
+ const markdown = 'https://www.topcoder-dev.com/challenges'
+
+ render()
+ const markdownProps = mockReactMarkdown.mock.calls[0][0] as MarkdownRendererProps
+ const MarkdownLink = markdownProps.components.a
+
+ render(
+
+ {markdown}
+ ,
+ )
+ const link = screen.getByRole('link', { name: markdown })
+
+ expect(link)
+ .toHaveAttribute('href', markdown)
+ expect(link)
+ .toHaveAttribute('target', '_blank')
+ expect(link)
+ .toHaveAttribute('rel', 'noopener noreferrer')
+ })
+
+ it('keeps Markdown formatting visible after the platform style reset', () => {
+ expect(markdownStyles)
+ .toMatch(/a,[\s\S]*a:hover \{[\s\S]*color: \$link-blue-dark;[\s\S]*text-decoration: underline;/)
+ expect(markdownStyles)
+ .toMatch(/strong \{[\s\S]*font-weight: \$font-weight-bold;/)
+ expect(markdownStyles)
+ .toMatch(/em \{[\s\S]*font-style: italic;/)
+ expect(markdownStyles)
+ .toMatch(/h1,[\s\S]*h2,[\s\S]*h3 \{[\s\S]*font-weight: \$font-weight-bold;/)
+ expect(markdownStyles)
+ .toMatch(/ol \{[\s\S]*list-style-type: decimal;/)
+ expect(markdownStyles)
+ .toMatch(/ul \{[\s\S]*list-style-type: disc;/)
+ })
+
+ it('limits conversation timeline styling to top-level messages', () => {
+ expect(ticketDetailStyles)
+ .toMatch(/\.timeline \{[\s\S]*> li \{/)
+ expect(ticketDetailStyles)
+ .toMatch(/\.timeline > li \{/)
+ expect(ticketDetailStyles)
+ .not.toMatch(/\.timeline li \{/)
+ })
+})
diff --git a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.tsx b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.tsx
index 8a0d1fb31..ce0c367d7 100644
--- a/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.tsx
+++ b/src/apps/support/src/lib/components/MarkdownContent/MarkdownContent.tsx
@@ -1,6 +1,6 @@
/** Safe Markdown renderer for user-authored support content. */
-import { FC } from 'react'
-import ReactMarkdown from 'react-markdown'
+import type { FC } from 'react'
+import ReactMarkdown, { type Components } from 'react-markdown'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
@@ -10,8 +10,28 @@ export interface MarkdownContentProps {
markdown: string
}
+const markdownComponents: Components = {
+ /**
+ * Renders a Markdown link in a new tab without exposing the opener page.
+ *
+ * @param props anchor attributes and AST metadata produced by ReactMarkdown.
+ * @returns a safely targeted anchor used for links in support conversations.
+ * @throws Does not throw.
+ */
+ a: props => (
+
+ {props.children}
+
+ ),
+}
+
/**
- * Renders GFM and line breaks while dropping raw HTML.
+ * Renders GFM and line breaks with links opening in new tabs while dropping raw HTML.
*
* @param props untrusted Markdown source.
* @returns safely rendered Markdown.
@@ -20,6 +40,7 @@ export interface MarkdownContentProps {
export const MarkdownContent: FC = props => (
diff --git a/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.spec.tsx b/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.spec.tsx
index cf72bf26a..1829612bf 100644
--- a/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.spec.tsx
+++ b/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.spec.tsx
@@ -144,7 +144,7 @@ describe('OpenSupportRequestModal', () => {
expect(screen.getByRole('dialog')
.getAttribute('data-size'))
.toBe('body')
- const challengeSelect = screen.getByLabelText('Challenge (if applicable)') as HTMLSelectElement
+ const challengeSelect = screen.getByLabelText('Active Challenge (if applicable)') as HTMLSelectElement
expect(challengeSelect.options[0].text)
.toBe('Select challenge')
expect(mockUseSWR.mock.calls[0][0])
@@ -155,7 +155,7 @@ describe('OpenSupportRequestModal', () => {
expect(mockedGetActiveChallenges)
.toHaveBeenCalledWith('12345')
- fireEvent.change(screen.getByLabelText('Challenge (if applicable)'), {
+ fireEvent.change(screen.getByLabelText('Active Challenge (if applicable)'), {
target: { value: 'challenge-2' },
})
fireEvent.change(screen.getByLabelText('Description'), {
@@ -209,7 +209,7 @@ describe('OpenSupportRequestModal', () => {
/>,
)
- fireEvent.change(screen.getByLabelText('Challenge (if applicable)'), {
+ fireEvent.change(screen.getByLabelText('Active Challenge (if applicable)'), {
target: { value: 'challenge-2' },
})
mockUseSWR.mockReturnValue({
diff --git a/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.tsx b/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.tsx
index d3ca825f9..a24197bc3 100644
--- a/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.tsx
+++ b/src/apps/support/src/lib/components/OpenSupportRequestModal/OpenSupportRequestModal.tsx
@@ -192,7 +192,7 @@ export const OpenSupportRequestModal: FC = props =
>
-
+
|