Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@datadog/browser-logs": "^4.50.1",
"@hello-pangea/dnd": "^18.0.1",
"@heroicons/react": "^1.0.6",
"@highcharts/map-collection": "^2.3.3",
"@hookform/resolvers": "^4.1.3",
"@popperjs/core": "^2.11.8",
"@sprig-technologies/sprig-browser": "^2.39.0",
Expand Down Expand Up @@ -125,7 +126,8 @@
"typescript": "^4.9.5",
"universal-navigation": "https://github.com/topcoder-platform/universal-navigation#master",
"uuid": "^11.1.0",
"yup": "^1.7.1"
"yup": "^1.7.1",
"flag-icons": "^6.7.0"
},
"devDependencies": {
"@babel/core": "^7.29.6",
Expand Down
1 change: 1 addition & 0 deletions src/apps/customer-portal/src/config/routes.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export const rootRoute: string
export const talentSearchRouteId = 'talent-search'
export const showcaseSearchRouteId = 'showcase'
export const flexiTalentRouteId = 'flexi-talent'
export const statisticsRouteId = 'statistics'
2 changes: 2 additions & 0 deletions src/apps/customer-portal/src/customer-portal.routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { customerPortalFlexiTalentRoutes } from './pages/flexi-talent/flexi-talent.routes'
import { customerPortalTalentSearchRoutes } from './pages/talent-search/talent-search.routes'
import { customerPortalProjectShowcaseRoutes } from './pages/project-showcase/project-showcase.routes'
import { customerPortalStatisticsRoutes } from './pages/statistics/statistics.routes'

const CustomerPortalApp: LazyLoadedComponent = lazyLoad(() => import('./CustomerPortalApp'))

Expand All @@ -32,6 +33,7 @@ export const customerPortalRoutes: ReadonlyArray<PlatformRoute> = [
element: <Rewrite to={talentSearchRouteId} />,
route: '',
},
...customerPortalStatisticsRoutes,
...customerPortalTalentSearchRoutes,
...customerPortalProjectShowcaseRoutes,
...customerPortalFlexiTalentRoutes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import { TabsNavItem } from '~/libs/ui'
import {
flexiTalentRouteId,
showcaseSearchRouteId,
statisticsRouteId,
talentSearchRouteId,
} from '~/apps/customer-portal/src/config/routes.config'

export function getTabsConfig(userRoles: string[], isAnonymous: boolean, isUnprivilegedUser: boolean): TabsNavItem[] {

const tabs: TabsNavItem[] = [
...(!isUnprivilegedUser ? [{
id: statisticsRouteId,
title: 'General Statistics',
}, {
id: talentSearchRouteId,
title: 'Talent Search',
}, {
Expand Down
1 change: 1 addition & 0 deletions src/apps/customer-portal/src/lib/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './talentSearch.service'
export * from './flexiTalent.service'
export * from './showcasePost.service'
export * from './statistics.service'
173 changes: 173 additions & 0 deletions src/apps/customer-portal/src/lib/services/statistics.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { EnvironmentConfig } from '~/config'
import { xhrGetAsync } from '~/libs/core'
import worldMap from '@highcharts/map-collection/custom/world.topo.json'

export const ISO3_TO_2 = new Map<string, string>(
((worldMap as any)?.objects?.default?.geometries ?? [])
.map((geometry: any) => {
const properties = geometry?.properties
const iso3 = String(properties?.['iso-a3'] ?? '')
.toUpperCase()
const iso2 = String(properties?.['iso-a2'] ?? '')
.toUpperCase()

return [iso3, iso2] as [string, string]
})
.filter(([iso3, iso2]: [string, string]) => iso3 && iso2),
)

function toAlpha2CountryCode(code: string): string {
const normalized = String(code || '')
.trim()
.toUpperCase()
return normalized.length === 3 ? ISO3_TO_2.get(normalized) ?? normalized : normalized
}

export type StatisticsCountry = {
code?: string
count: number
flagUrl?: string
name: string
}

export type GeneralStatistics = {
completedChallenges: number
countries: StatisticsCountry[]
memberCount: number
totalPrizes: number
}

type CountryReportRow = {
'challenge_stats.count'?: number | string
'country.country_name'?: string
'user.count'?: number | string
}

type CountryLookupRow = {
countryCode?: string
countryFlag?: string
name?: string
}

type CountryLookupResponse = {
result?: CountryLookupRow[]
}

const GENERAL_STATISTICS_URL = `${EnvironmentConfig.REPORTS_API}/statistics/general`
const COUNTRY_LOOKUP_URL = `${EnvironmentConfig.API.V6}/lookups/countries?page=1&perPage=9999`

const COUNTRY_NAME_ALIASES: Record<string, string> = {
'bosnia and herzegovina': 'bosnia and herzegowina',
'czech republic': 'czechia',
'iran islamic republic of': 'iran',
'korea democratic peoples republic of': 'north korea',
'korea republic of': 'south korea',
'lao peoples democratic republic': 'laos',
// 'macedonia the former yugoslav republic of': 'north macedonia',
'macedonia the former yugoslav republic of': 'macedonia former yugoslav rep of',
'moldova republic of': 'moldova',
'russian federation': 'russia',
'syrian arab republic': 'syria',
'taiwan province of china': 'taiwan',
'tanzania united republic of': 'tanzania',
'united states': 'united states of america',
'venezuela bolivarian republic of': 'venezuela',
'viet nam': 'vietnam',
}

function normalizeCountryName(name: string): string {
const normalized = name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9]+/g, ' ')
.trim()
.toLowerCase()

return COUNTRY_NAME_ALIASES[normalized] || normalized
}

function unwrapCountryLookups(response: CountryLookupResponse | CountryLookupRow[]): CountryLookupRow[] {
if (Array.isArray(response)) {
return response
}

return Array.isArray(response?.result) ? response.result : []
}

function normalizeCountryRows(
rows: CountryReportRow[],
countKey: 'challenge_stats.count' | 'user.count',
lookups: CountryLookupRow[],
): StatisticsCountry[] {
const lookupsByName = new Map<string, CountryLookupRow>()
lookups.forEach(lookup => {
if (lookup.name) {
lookupsByName.set(normalizeCountryName(lookup.name), lookup)
}
})

const countries = new Map<string, StatisticsCountry>()
rows.forEach(row => {
const name = String(row['country.country_name'] || '')
.trim()
const count = Number(row[countKey] || 0)
const normalizedName = normalizeCountryName(name)
const lookup = lookupsByName.get(normalizedName)

// Ignore malformed report rows and values that cannot be mapped to a real country.
if (!name || !Number.isFinite(count) || count <= 0 || !lookup?.countryCode) {
return
}

const code = toAlpha2CountryCode(lookup.countryCode)
const current = countries.get(code)
countries.set(code, {
code,
count: (current?.count || 0) + count,
// flagUrl: lookup.countryFlag?.replace(/^http:/, 'https:'),
name: lookup.name || name,
})
})

return Array.from(countries.values())
.sort((countryA, countryB) => countryB.count - countryA.count)
}

export async function fetchCountriesRepresented(): Promise<StatisticsCountry[]> {
const [rows, lookupResponse] = await Promise.all([
xhrGetAsync<CountryReportRow[]>(`${GENERAL_STATISTICS_URL}/countries-represented`),
xhrGetAsync<CountryLookupResponse | CountryLookupRow[]>(COUNTRY_LOOKUP_URL),
])

return normalizeCountryRows(rows, 'user.count', unwrapCountryLookups(lookupResponse))
}

export async function fetchWinnersByCountry(): Promise<StatisticsCountry[]> {
const [rows, lookupResponse] = await Promise.all([
xhrGetAsync<CountryReportRow[]>(`${GENERAL_STATISTICS_URL}/first-place-by-country`),
xhrGetAsync<CountryLookupResponse | CountryLookupRow[]>(COUNTRY_LOOKUP_URL),
])

return normalizeCountryRows(rows, 'challenge_stats.count', unwrapCountryLookups(lookupResponse))
}

export async function fetchGeneralStatistics(): Promise<GeneralStatistics> {
const [
memberCountResponse,
totalPrizesResponse,
completedChallengesResponse,
countries,
] = await Promise.all([
xhrGetAsync<{ 'user.count'?: number }>(`${GENERAL_STATISTICS_URL}/member-count`),
xhrGetAsync<{ total?: number | string }>(`${GENERAL_STATISTICS_URL}/total-prizes`),
xhrGetAsync<{ 'challenge.count'?: number }>(`${GENERAL_STATISTICS_URL}/completed-challenges`),
fetchCountriesRepresented(),
])

return {
completedChallenges: Number(completedChallengesResponse['challenge.count'] || 0),
countries,
memberCount: Number(memberCountResponse['user.count'] || 0),
totalPrizes: Number(totalPrizesResponse.total || 0),
}
}
Loading
Loading