From 6f43cea3d0fd4bbf310b527eb665faa8455a96af Mon Sep 17 00:00:00 2001 From: himaniraghav3 Date: Fri, 21 Aug 2026 07:32:25 +0530 Subject: [PATCH] PM-4699 Add bubble skill statistics UI with mock data --- .../src/config/routes.config.ts | 1 + .../src/customer-portal.routes.tsx | 2 + .../components/NavTabs/config/tabs-config.ts | 4 + .../SkillBubblesChart.module.scss | 300 ++++++++++++ .../SkillStatisticsPage/SkillBubblesChart.tsx | 463 ++++++++++++++++++ .../SkillMembersPanel.module.scss | 268 ++++++++++ .../SkillStatisticsPage/SkillMembersPanel.tsx | 202 ++++++++ .../SkillStatisticsPage.module.scss | 50 ++ .../SkillStatisticsPage.spec.tsx | 170 +++++++ .../SkillStatisticsPage.tsx | 61 +++ .../SkillStatisticsPage/index.ts | 1 + .../SkillStatisticsPage/mock/index.ts | 2 + .../mock/skill-categories.mock.ts | 377 ++++++++++++++ .../mock/skill-members.mock.ts | 192 ++++++++ .../SkillStatisticsPage/packCircles.ts | 137 ++++++ .../skill-statistics.routes.tsx | 26 + 16 files changed, 2256 insertions(+) create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/index.ts create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/index.ts create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-categories.mock.ts create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-members.mock.ts create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts create mode 100644 src/apps/customer-portal/src/pages/skill-statistics/skill-statistics.routes.tsx diff --git a/src/apps/customer-portal/src/config/routes.config.ts b/src/apps/customer-portal/src/config/routes.config.ts index c45b3e81e..0e5a652a0 100644 --- a/src/apps/customer-portal/src/config/routes.config.ts +++ b/src/apps/customer-portal/src/config/routes.config.ts @@ -12,3 +12,4 @@ export const talentSearchRouteId = 'talent-search' export const showcaseSearchRouteId = 'showcase' export const flexiTalentRouteId = 'flexi-talent' export const statisticsRouteId = 'statistics' +export const skillStatisticsRouteId = 'skill-statistics' diff --git a/src/apps/customer-portal/src/customer-portal.routes.tsx b/src/apps/customer-portal/src/customer-portal.routes.tsx index 3e9cb2249..d0033c8be 100644 --- a/src/apps/customer-portal/src/customer-portal.routes.tsx +++ b/src/apps/customer-portal/src/customer-portal.routes.tsx @@ -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 { customerPortalSkillStatisticsRoutes } from './pages/skill-statistics/skill-statistics.routes' import { customerPortalStatisticsRoutes } from './pages/statistics/statistics.routes' const CustomerPortalApp: LazyLoadedComponent = lazyLoad(() => import('./CustomerPortalApp')) @@ -34,6 +35,7 @@ export const customerPortalRoutes: ReadonlyArray = [ route: '', }, ...customerPortalStatisticsRoutes, + ...customerPortalSkillStatisticsRoutes, ...customerPortalTalentSearchRoutes, ...customerPortalProjectShowcaseRoutes, ...customerPortalFlexiTalentRoutes, diff --git a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts index c01e6ff0e..75c25f041 100644 --- a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts +++ b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts @@ -4,6 +4,7 @@ import { TabsNavItem } from '~/libs/ui' import { flexiTalentRouteId, showcaseSearchRouteId, + skillStatisticsRouteId, statisticsRouteId, talentSearchRouteId, } from '~/apps/customer-portal/src/config/routes.config' @@ -14,6 +15,9 @@ export function getTabsConfig(userRoles: string[], isAnonymous: boolean, isUnpri ...(!isUnprivilegedUser ? [{ id: statisticsRouteId, title: 'General Statistics', + }, { + id: skillStatisticsRouteId, + title: 'Skill Statistics', }, { id: talentSearchRouteId, title: 'Talent Search', diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss new file mode 100644 index 000000000..da0416245 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.module.scss @@ -0,0 +1,300 @@ +@import '@libs/ui/styles/includes'; + +.chart { + height: 560px; + min-height: 560px; + overflow: visible; + position: relative; + width: 100%; + + @include ltemd { + height: 420px; + min-height: 420px; + } +} + +.bubble { + align-items: center; + border: 0; + border-radius: 50%; + color: #fff; + cursor: pointer; + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; + padding: 8px; + position: absolute; + text-align: center; + transform: translate(-50%, -50%); + transition: box-shadow 160ms ease, transform 160ms ease; + z-index: 1; + + svg { + color: #fff; + flex: 0 0 auto; + margin-bottom: 4px; + } + + &:hover, + &:focus-visible, + &.hovered { + box-shadow: 0 10px 28px rgba(10, 10, 10, 0.35); + z-index: 3; + } + + &:focus-visible { + outline: 2px solid #078477; + outline-offset: 3px; + } +} + +.selected { + box-shadow: 0 12px 32px rgba(10, 10, 10, 0.4); + z-index: 4; +} + +.label { + display: -webkit-box; + font-family: 'Nunito Sans', sans-serif; + font-weight: 700; + line-height: 1.15; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.popover { + background: #0f172a; + border-radius: 8px; + box-sizing: border-box; + color: #fff; + display: flex; + flex-direction: column; + font-family: 'Figtree', sans-serif; + gap: 16px; + padding: 24px; + pointer-events: none; + position: fixed; + transform: translate(-50%, calc(-100% - 8px)); + width: 320px; + z-index: 2147483647; + + &::after { + border-left: 9px solid transparent; + border-right: 9px solid transparent; + border-top: 8px solid #0f172a; + content: ''; + height: 0; + left: 50%; + position: absolute; + top: 100%; + transform: translateX(-50%); + width: 0; + } + + &.below { + transform: translate(-50%, 8px); + + &::after { + border-bottom: 8px solid #0f172a; + border-top: 0; + bottom: 100%; + top: auto; + } + } + + &.left { + transform: translate(calc(-100% - 8px), 0); + + &::after { + border-bottom: 9px solid transparent; + border-left: 8px solid #0f172a; + border-right: 0; + border-top: 9px solid transparent; + left: 100%; + top: var(--arrow-offset, 50%); + transform: translateY(-50%); + } + } + + &.right { + transform: translate(8px, 0); + + &::after { + border-bottom: 9px solid transparent; + border-left: 0; + border-right: 8px solid #0f172a; + border-top: 9px solid transparent; + left: auto; + right: 100%; + top: var(--arrow-offset, 50%); + transform: translateY(-50%); + } + } +} + +.popoverTitle { + font-size: 18px; + font-weight: 700; + line-height: normal; +} + +.metrics { + display: grid; + gap: 40px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.metric { + display: flex; + flex-direction: column; + font-size: 12px; + gap: 3px; +} + +.metricValue { + align-items: center; + display: flex; + gap: 5px; + + img { + flex: 0 0 24px; + height: 24px; + width: 24px; + } + + strong { + font-size: 24px; + font-weight: 600; + line-height: normal; + white-space: nowrap; + } +} + +.breakdown { + display: flex; + flex-direction: column; + font-size: 12px; + gap: 10px; +} + +.bar { + border-radius: 4px; + display: flex; + height: 24px; + overflow: hidden; + width: 100%; +} + +.segment { + align-items: center; + display: flex; + flex: 0 0 auto; + justify-content: center; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} + +.legend { + align-items: center; + display: flex; + justify-content: space-between; +} + +.legendItem { + align-items: center; + display: flex; + gap: 8px; + min-width: 0; + + > span:last-child { + max-width: 70px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.dot { + border-radius: 50%; + flex: 0 0 9px; + height: 9px; + width: 9px; +} + +.topMember { + display: flex; + flex-direction: column; + gap: 8px; +} + +.topMemberContent { + align-items: center; + display: flex; + gap: 14px; + min-width: 0; +} + +.avatar { + background-color: #d9d9d9; + background-position: center; + background-size: cover; + border-radius: 50%; + display: block; + flex: 0 0 40px; + height: 40px; + overflow: hidden; + position: relative; + width: 40px; +} + +.avatarHead { + background: #aab6c2; + border-radius: 50%; + height: 14px; + left: 13px; + position: absolute; + top: 7px; + width: 14px; +} + +.avatarBody { + background: #aab6c2; + border-radius: 16px 16px 8px 8px; + bottom: -2px; + height: 18px; + left: 7px; + position: absolute; + width: 26px; +} + +.handle { + font-size: 16px; + font-weight: 600; + line-height: normal; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #3877EA; +} + +.memberStats { + align-items: center; + display: flex; + font-size: 12px; + gap: 5px; + line-height: normal; + white-space: nowrap; +} + +.flag { + flex: 0 0 16px; + height: 16px; + width: 16px; +} + +.divider { + margin: 0 5px; +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx new file mode 100644 index 000000000..a4e6b022f --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillBubblesChart.tsx @@ -0,0 +1,463 @@ +/* eslint-disable react/jsx-no-bind, no-use-before-define */ +import { + CSSProperties, + FC, + KeyboardEvent, + RefObject, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { createPortal } from 'react-dom' +import classNames from 'classnames' + +import { getRatingColor } from '~/libs/core' + +import memberGroupIcon from '../../statistics/StatisticsPage/assets/member-group.svg' +import skillCognitionIcon from '../../statistics/StatisticsPage/assets/skill-cognition.svg' + +import { + getTopMemberForCategory, + SkillCategoryMock, + SkillMemberMock, +} from './mock' +import { packCircles, PackedCircle } from './packCircles' +import styles from './SkillBubblesChart.module.scss' + +const NUMBER_FORMATTER = new Intl.NumberFormat('en-US') +const SKILL_COLORS = ['#c1294f', '#00797a', '#fdc220', '#a6a6a6'] +const POPOVER_GAP = 12 +const POPOVER_ESTIMATED_HEIGHT = 340 +const POPOVER_WIDTH = 320 +const VIEW_PAD = 8 +const MIN_BUBBLE_FONT_SIZE = 12 +const MAX_BUBBLE_FONT_SIZE = 20 + +type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right' + +type PopoverLayout = { + arrowOffset?: number + left: number + placement: PopoverPlacement + top: number +} + +type ChartRect = { + height: number + left: number + top: number + width: number +} + +function getPopoverLayout( + circle: PackedCircle, + chartRect: ChartRect, + popoverWidth: number, + popoverHeight: number, +): PopoverLayout { + const viewWidth = typeof window === 'undefined' ? chartRect.width : window.innerWidth + const viewHeight = typeof window === 'undefined' ? chartRect.height : window.innerHeight + const centerX = chartRect.left + circle.x + const centerY = chartRect.top + circle.y + const bubbleTop = centerY - circle.r + const bubbleBottom = centerY + circle.r + const bubbleLeft = centerX - circle.r + const bubbleRight = centerX + circle.r + const spaceLeft = bubbleLeft - VIEW_PAD + const spaceRight = viewWidth - VIEW_PAD - bubbleRight + const fitsTop = bubbleTop - POPOVER_GAP - popoverHeight >= VIEW_PAD + const fitsBottom = bubbleBottom + POPOVER_GAP + popoverHeight <= viewHeight - VIEW_PAD + const fitsLeft = spaceLeft >= popoverWidth + POPOVER_GAP + const fitsRight = spaceRight >= popoverWidth + POPOVER_GAP + + let placement: PopoverPlacement = 'top' + if (fitsTop) { + placement = 'top' + } else if (fitsLeft && fitsRight) { + placement = spaceRight >= spaceLeft ? 'right' : 'left' + } else if (fitsRight) { + placement = 'right' + } else if (fitsLeft) { + placement = 'left' + } else if (fitsBottom) { + placement = 'bottom' + } else { + placement = spaceRight >= spaceLeft ? 'right' : 'left' + } + + if (placement === 'top') { + return { + left: centerX, + placement, + top: bubbleTop, + } + } + + if (placement === 'bottom') { + return { + left: centerX, + placement, + top: bubbleBottom, + } + } + + const desiredTop = centerY - (popoverHeight / 2) + const clampedTop = Math.min( + Math.max(desiredTop, VIEW_PAD), + viewHeight - VIEW_PAD - popoverHeight, + ) + + return { + arrowOffset: centerY - clampedTop, + left: placement === 'right' ? bubbleRight : bubbleLeft, + placement, + top: clampedTop, + } +} + +interface SkillBubblesChartProps { + categories: SkillCategoryMock[] + onSelect: (categoryId: string) => void + selectedCategoryId?: string +} + +function radiusForSize(size: number): number { + return 28 + (size * 9) +} + +function fontSizeForRadius( + radius: number, + minRadius: number, + maxRadius: number, +): number { + if (maxRadius <= minRadius) { + return (MIN_BUBBLE_FONT_SIZE + MAX_BUBBLE_FONT_SIZE) / 2 + } + + const t = (radius - minRadius) / (maxRadius - minRadius) + + return MIN_BUBBLE_FONT_SIZE + (t * (MAX_BUBBLE_FONT_SIZE - MIN_BUBBLE_FONT_SIZE)) +} + +const SkillBubblesChart: FC = props => { + const chartRef = useRef(null) + const [hoveredCategoryId, setHoveredCategoryId] = useState() + const [viewport, setViewport] = useState({ height: 560, width: 960 }) + + useEffect(() => { + const node = chartRef.current + if (!node) { + return undefined + } + + const measure = (): void => { + setViewport(current => { + const height = Math.max(node.clientHeight, 1) + const width = Math.max(node.clientWidth, 1) + + return current.width === width && current.height === height + ? current + : { height, width } + }) + } + + measure() + window.addEventListener('resize', measure) + + const observer = typeof ResizeObserver === 'undefined' + ? undefined + : new ResizeObserver(measure) + observer?.observe(node) + + return () => { + window.removeEventListener('resize', measure) + observer?.disconnect() + } + }, []) + + const packed = useMemo( + () => packCircles( + props.categories.map(category => ({ + id: category.id, + r: radiusForSize(category.size), + })), + viewport.width, + viewport.height, + ), + [props.categories, viewport.height, viewport.width], + ) + + const packedById = useMemo( + () => new Map(packed.map(circle => [circle.id, circle])), + [packed], + ) + const packedRadii = useMemo( + () => packed.map(circle => circle.r), + [packed], + ) + const minPackedRadius = packedRadii.length ? Math.min(...packedRadii) : 0 + const maxPackedRadius = packedRadii.length ? Math.max(...packedRadii) : 0 + + const hoveredCategory = props.categories.find( + category => category.id === hoveredCategoryId, + ) + const hoveredCircle = hoveredCategory + ? packedById.get(hoveredCategory.id) + : undefined + const topMember = hoveredCategory + ? getTopMemberForCategory(hoveredCategory.id) + : undefined + + const handleKeyDown = useCallback(( + event: KeyboardEvent, + categoryId: string, + ) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + props.onSelect(categoryId) + } + }, [props]) + + return ( +
+ {props.categories.map(category => { + const circle = packedById.get(category.id) + if (!circle) { + return undefined + } + + const Icon = category.icon + const isSelected = category.id === props.selectedCategoryId + const fontSize = fontSizeForRadius( + circle.r, + minPackedRadius, + maxPackedRadius, + ) + const iconSize = Math.max(14, Math.min(28, circle.r / 4.6)) + + return ( + + ) + })} + {hoveredCategory && hoveredCircle && ( + + )} +
+ ) +} + +interface SkillCategoryPopoverProps { + category: SkillCategoryMock + chartHeight: number + chartRef: RefObject + chartWidth: number + circle: PackedCircle + topMember?: SkillMemberMock +} + +const SkillCategoryPopover = (props: SkillCategoryPopoverProps): JSX.Element => { + const popoverRef = useRef(null) + const [layout, setLayout] = useState({ + left: props.circle.x, + placement: 'top', + top: props.circle.y - props.circle.r, + }) + + useLayoutEffect(() => { + const update = (): void => { + const chartNode = props.chartRef.current + const chartRect = chartNode?.getBoundingClientRect() + const measured: ChartRect = chartRect && chartRect.width > 0 + ? chartRect + : { + height: props.chartHeight, + left: 0, + top: 0, + width: props.chartWidth, + } + const height = popoverRef.current?.offsetHeight || POPOVER_ESTIMATED_HEIGHT + const width = popoverRef.current?.offsetWidth || POPOVER_WIDTH + setLayout(getPopoverLayout(props.circle, measured, width, height)) + } + + update() + window.addEventListener('resize', update) + window.addEventListener('scroll', update, true) + + return () => { + window.removeEventListener('resize', update) + window.removeEventListener('scroll', update, true) + } + }, [props.category.id, props.chartHeight, props.chartRef, props.chartWidth, props.circle]) + + const topSkillsPercentage = props.category.skillsBreakdown.reduce( + (total, skill) => total + skill.percentage, + 0, + ) + const skills = [ + ...props.category.skillsBreakdown, + { + name: 'Others', + percentage: Math.max(100 - topSkillsPercentage, 0), + }, + ].filter(skill => skill.percentage > 0) + const countryCode = /^[A-Z]{2}$/.test(props.topMember?.countryCode || '') + ? props.topMember?.countryCode.toLowerCase() + : '' + const popoverStyle: CSSProperties = { + left: layout.left, + top: layout.top, + } + + if (layout.arrowOffset !== undefined) { + Object.assign(popoverStyle, { '--arrow-offset': `${layout.arrowOffset}px` }) + } + + const popover = ( +
+ {props.category.name} +
+
+ Total Members + + + {NUMBER_FORMATTER.format(props.category.totalMembers)} + +
+
+ Total Skills + + + {NUMBER_FORMATTER.format(props.category.totalSkills)} + +
+
+
+ Sub-Skill Breakdown +
+ {skills.map((skill, index) => ( + + {`${skill.percentage}%`} + + ))} +
+
+ {skills.map((skill, index) => ( + + + {skill.name} + + ))} +
+
+ {props.topMember && ( +
+ Top Member +
+ + + + + + + {props.topMember.handle} + + + {countryCode && ( + + +
+
+ )} +
+ ) + + return typeof document === 'undefined' + ? popover + : createPortal(popover, document.body) +} + +export default SkillBubblesChart diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss new file mode 100644 index 000000000..35b950ec8 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.module.scss @@ -0,0 +1,268 @@ +@import '@libs/ui/styles/includes'; + +.section { + margin-top: 40px; +} + +.header { + h2 { + color: #151515; + font-family: 'Figtree', sans-serif; + font-size: 26px; + font-weight: 700; + line-height: 30px; + margin: 0; + text-transform: none; + } +} + +.subtitle { + color: #0a0a0a; + font-size: 18px; + line-height: 25px; + margin: 4px 0 0; +} + +.body { + display: grid; + gap: 32px; + grid-template-columns: minmax(220px, 240px) minmax(0, 1fr); + margin-top: 24px; + + @include ltemd { + grid-template-columns: 1fr; + } +} + +.filters { + display: flex; + flex-direction: column; + gap: 16px; +} + +.search { + position: relative; + + input { + background: #fff; + border: 1px solid #a8a8a8; + border-radius: 4px; + color: #0a0a0a; + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + height: 40px; + line-height: 22px; + padding: 8px 40px 8px 12px; + width: 100%; + + &:focus-visible { + outline: 2px solid #078477; + outline-offset: 1px; + } + } + + svg { + color: #767676; + height: 20px; + pointer-events: none; + position: absolute; + right: 12px; + top: 10px; + width: 20px; + } +} + +.filter { + display: flex; + flex-direction: column; + gap: 6px; + + span { + color: #0a0a0a; + font-family: 'Nunito Sans', sans-serif; + font-size: 12px; + font-weight: 700; + line-height: 16px; + } + + select { + appearance: none; + background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%230a0a0a'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E") no-repeat right 12px center; + background-size: 18px; + border: 1px solid #a8a8a8; + border-radius: 4px; + color: #0a0a0a; + font-family: 'Nunito Sans', sans-serif; + font-size: 16px; + height: 40px; + line-height: 22px; + padding: 8px 36px 8px 12px; + width: 100%; + + &:focus-visible { + outline: 2px solid #078477; + outline-offset: 1px; + } + } +} + +$row-height: 64px; +$visible-member-rows: 10; + +.tableWrap { + max-height: $row-height * ($visible-member-rows + 1); + overflow: auto; + + table { + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; + width: 100%; + } + + thead { + position: sticky; + top: 0; + z-index: 3; + } + + th, + td { + border-bottom: 1px solid #e2e2e2; + color: #1a1a1a; + font-size: 14px; + height: $row-height; + line-height: 20px; + padding: 12px 16px; + text-align: left; + vertical-align: middle; + } + + th { + background: #fff; + border-bottom-color: #a8a8a8; + font-weight: 700; + position: sticky; + top: 0; + z-index: 3; + } + + tbody td { + background: #fff; + position: relative; + z-index: 0; + } + + .avatar { + z-index: 0; + } + + th:first-child, + td:first-child { + text-align: center; + width: 64px; + } + + th:nth-child(3), + td:nth-child(3) { + width: 88px; + } + + th:nth-child(4), + td:nth-child(4) { + width: 140px; + } + + th:last-child, + td:last-child { + text-align: right; + width: 110px; + } +} + +.memberCell { + align-items: center; + display: flex; + gap: 12px; + min-width: 0; +} + +.avatar { + flex: 0 0 40px; + height: 40px; + width: 40px; + + :global(span) { + font-size: 14px !important; + } +} + +.memberText { + display: flex; + flex-direction: column; + min-width: 0; +} + +.handle { + font-weight: 700; + overflow: hidden; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover { + text-decoration: underline; + } +} + +.memberName { + color: #767676; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.countryCell { + align-items: center; + display: flex; + gap: 8px; + min-width: 0; + + span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.flag { + display: inline-flex; + flex: 0 0 auto; + height: 14px; + width: 22px; +} + +.rank1, +.rank2, +.rank3 { + align-items: center; + display: inline-flex; + height: 20px; + justify-content: center; + width: 20px; +} + +.rank4 { + display: inline-block; + font-size: 14px; + line-height: 20px; + min-width: 19px; + text-align: center; +} + +.empty { + color: #545f71; + font-size: 14px; + padding: 24px 16px; + text-align: center; +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx new file mode 100644 index 000000000..a927bab44 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillMembersPanel.tsx @@ -0,0 +1,202 @@ +/* eslint-disable react/jsx-no-bind */ +import { ChangeEvent, FC, useMemo } from 'react' +import classNames from 'classnames' + +import { EnvironmentConfig } from '~/config' +import { getRatingColor } from '~/libs/core' +import { ProfilePicture } from '~/libs/shared' +import { IconOutline } from '~/libs/ui' + +import { + IconFirstPlace, + IconSecondPlace, + IconThirdPlace, +} from '../../statistics/StatisticsPage/assets' + +import { SkillCategoryMock, SkillMemberMock } from './mock' +import styles from './SkillMembersPanel.module.scss' + +const NUMBER_FORMATTER = new Intl.NumberFormat('en-US') + +interface SkillMembersPanelProps { + category: SkillCategoryMock + countryFilter: string + members: SkillMemberMock[] + onCountryChange: (countryCode: string) => void + onSearchChange: (value: string) => void + search: string +} + +const SkillMembersPanel: FC = props => { + const countryOptions = useMemo(() => { + const unique = new Map() + props.members.forEach(member => { + if (member.countryCode && member.countryName) { + unique.set(member.countryCode, member.countryName) + } + }) + + return Array.from(unique.entries()) + .map(([code, name]) => ({ code, name })) + .sort((left, right) => left.name.localeCompare(right.name)) + }, [props.members]) + + const visibleMembers = useMemo(() => { + const query = props.search.trim() + .toLowerCase() + + return props.members + .filter(member => { + const matchesSearch = !query + || member.handle.toLowerCase() + .includes(query) + || member.name.toLowerCase() + .includes(query) + const matchesCountry = !props.countryFilter + || member.countryCode === props.countryFilter + + return matchesSearch && matchesCountry + }) + .sort((left, right) => right.wins - left.wins) + }, [props.countryFilter, props.members, props.search]) + + return ( +
+
+

{`Members for ${props.category.name}`}

+

+ Browse top 100 talent by skills and numbers of wins. +

+
+
+ +
+ + + + + + + + + + + + {visibleMembers.length === 0 && ( + + + + )} + {visibleMembers.map((member, index) => { + const rankIcon = index === 0 + ? + + + + + + + ) + })} + +
RankMemberRatingCountry# of Wins
+ No members match the current filters. +
+ + {rankIcon} + + +
+ +
+ + {member.handle} + + {member.name} +
+
+
+ + {member.rating} + + +
+ {countryCode && ( +
+
{NUMBER_FORMATTER.format(member.wins)}
+
+
+
+ ) +} + +export default SkillMembersPanel diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss new file mode 100644 index 000000000..f55ffd6be --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.module.scss @@ -0,0 +1,50 @@ +@import '@libs/ui/styles/includes'; + +.page { + color: #0a0a0a; + display: flex; + flex-direction: column; + font-family: 'Nunito Sans', sans-serif; + overflow-x: hidden; + padding: 8px 0 0; +} + +.header { + h1 { + color: #151515; + font-family: 'Figtree', sans-serif; + font-size: 32px; + font-weight: 700; + line-height: 38px; + margin-top: 20px; + text-transform: none; + } +} + +.subtitle { + color: #0a0a0a; + font-size: 18px; + line-height: 25px; + margin: 4px 0 0; +} + +.chartPanel { + box-sizing: border-box; + display: flex; + flex-direction: column; + left: 50%; + margin-top: 16px; + max-width: 100vw; + padding: 0 32px; + position: relative; + transform: translateX(-50%); + width: 100vw; +} + +.hint { + color: #0a0a0a; + font-size: 16px; + line-height: 22px; + margin: 20px 0 8px; + text-align: center; +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx new file mode 100644 index 000000000..afedc26e3 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.spec.tsx @@ -0,0 +1,170 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { fireEvent, render, screen, within } from '@testing-library/react' + +import { getTabIdFromPathName, getTabsConfig } from '../../../lib/components/NavTabs/config/tabs-config' +import { SKILL_CATEGORIES } from './mock' +import SkillStatisticsPage from './SkillStatisticsPage' + +jest.mock('~/config', () => ({ + AppSubdomain: { + customer: 'customer', + }, + EnvironmentConfig: { + SUBDOMAIN: 'customer', + USER_PROFILE_URL: 'https://profiles.example.com', + }, +}), { + virtual: true, +}) + +jest.mock('~/libs/core', () => ({ + getRatingColor: (rating?: number) => (rating && rating >= 2200 ? '#EF3A3A' : '#F2C900'), +}), { + virtual: true, +}) + +jest.mock('~/libs/shared', () => ({ + ProfilePicture: () => , +}), { + virtual: true, +}) + +const DummyIcon = (): JSX.Element => + +jest.mock('~/libs/ui', () => ({ + IconOutline: new Proxy({}, { + get: () => DummyIcon, + }), + TabsNavItem: {}, +}), { + virtual: true, +}) + +jest.mock('~/apps/customer-portal/src/config/routes.config', () => ({ + flexiTalentRouteId: 'flexi-talent', + showcaseSearchRouteId: 'showcase', + skillStatisticsRouteId: 'skill-statistics', + statisticsRouteId: 'statistics', + talentSearchRouteId: 'talent-search', +}), { + virtual: true, +}) + +jest.mock('flag-icons/css/flag-icons.min.css', () => ({}), { + virtual: true, +}) + +jest.mock('../../statistics/StatisticsPage/assets', () => ({ + IconFirstPlace: () => 1st, + IconSecondPlace: () => 2nd, + IconThirdPlace: () => 3rd, +}), { + virtual: true, +}) + +jest.mock('../../statistics/StatisticsPage/assets/member-group.svg', () => 'member-group.svg', { + virtual: true, +}) + +jest.mock('../../statistics/StatisticsPage/assets/skill-cognition.svg', () => 'skill-cognition.svg', { + virtual: true, +}) + +describe('Customer Portal Skill Statistics tabs', () => { + it('adds Skill Statistics beside General Statistics', () => { + const tabs = getTabsConfig(['administrator'], false, false) + + expect(tabs.map(tab => tab.title)) + .toEqual([ + 'General Statistics', + 'Skill Statistics', + 'Talent Search', + 'Showcase', + 'Flexi-Talent', + ]) + expect(getTabIdFromPathName('/skill-statistics', ['administrator'], false, false)) + .toBe('skill-statistics') + expect(getTabIdFromPathName('/statistics', ['administrator'], false, false)) + .toBe('statistics') + }) +}) + +describe('SkillStatisticsPage', () => { + it('renders all 23 skill categories', () => { + render() + + expect(SKILL_CATEGORIES) + .toHaveLength(23) + expect(screen.queryByRole('button', { name: 'Bar' })) + .not.toBeInTheDocument() + SKILL_CATEGORIES.forEach(category => { + expect(screen.getByRole('button', { name: category.name })) + .toBeInTheDocument() + }) + }) + + it('shows the category popover on hover and the members UI on click', () => { + render() + + const bubble = screen.getByRole('button', { name: 'Programming & Development' }) + fireEvent.mouseEnter(bubble) + + expect(screen.getByText('Total Members')) + .toBeInTheDocument() + expect(screen.getByText('banerjeesourish')) + .toBeInTheDocument() + expect(screen.getByText('Total Members') + .closest('[data-placement]')) + .toHaveAttribute('data-placement', expect.stringMatching(/^(top|bottom|left|right)$/)) + expect(screen.queryByRole('heading', { name: 'Members for Programming & Development' })) + .not.toBeInTheDocument() + + fireEvent.click(bubble) + + expect(screen.getByRole('heading', { name: 'Members for Programming & Development' })) + .toBeInTheDocument() + expect(screen.getByText('billzedison')) + .toBeInTheDocument() + }) + + it('filters members from in-memory state when searching', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Programming & Development' })) + + fireEvent.change(screen.getByLabelText('Search members'), { + target: { value: 'Ghostar' }, + }) + + const table = screen.getByRole('table') + expect(within(table) + .getByText('Ghostar')) + .toBeInTheDocument() + expect(within(table) + .queryByText('billzedison')) + .not.toBeInTheDocument() + expect(within(table) + .getByText('1st')) + .toBeInTheDocument() + }) + + it('reranks members when filtering by country', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Programming & Development' })) + + fireEvent.change(screen.getByLabelText('Filter By'), { + target: { value: 'GB' }, + }) + + const table = screen.getByRole('table') + expect(within(table) + .getByText('diazx')) + .toBeInTheDocument() + expect(within(table) + .queryByText('billzedison')) + .not.toBeInTheDocument() + expect(within(table) + .getByText('1st')) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx new file mode 100644 index 000000000..a85e28e49 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/SkillStatisticsPage.tsx @@ -0,0 +1,61 @@ +import { FC, useCallback, useMemo, useState } from 'react' +import 'flag-icons/css/flag-icons.min.css' + +import { getMembersForCategory, SKILL_CATEGORIES } from './mock' +import SkillBubblesChart from './SkillBubblesChart' +import SkillMembersPanel from './SkillMembersPanel' +import styles from './SkillStatisticsPage.module.scss' + +const SkillStatisticsPage: FC = () => { + const [selectedCategoryId, setSelectedCategoryId] = useState() + const [search, setSearch] = useState('') + const [countryFilter, setCountryFilter] = useState('') + + const selectedCategory = useMemo( + () => SKILL_CATEGORIES.find(category => category.id === selectedCategoryId), + [selectedCategoryId], + ) + const selectedMembers = useMemo( + () => (selectedCategoryId ? getMembersForCategory(selectedCategoryId) : []), + [selectedCategoryId], + ) + + const selectCategory = useCallback((categoryId: string) => { + setSelectedCategoryId(categoryId) + setSearch('') + setCountryFilter('') + }, []) + + return ( +
+
+

Skill Statistics

+

+ Browse and connect with verified experts across 23 skill categories. +

+
+ +
+

Select a skill category to see additional details

+ +
+ + {selectedCategory && ( + + )} +
+ ) +} + +export default SkillStatisticsPage diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/index.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/index.ts new file mode 100644 index 000000000..4e9fd8917 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/index.ts @@ -0,0 +1 @@ +export { default as SkillStatisticsPage } from './SkillStatisticsPage' diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/index.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/index.ts new file mode 100644 index 000000000..e21177854 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/index.ts @@ -0,0 +1,2 @@ +export * from './skill-categories.mock' +export * from './skill-members.mock' diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-categories.mock.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-categories.mock.ts new file mode 100644 index 000000000..47e2bb333 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-categories.mock.ts @@ -0,0 +1,377 @@ +import { FC, SVGProps } from 'react' + +import { IconOutline } from '~/libs/ui' + +export type SkillCategoryIcon = FC> + +export type SkillCategoryMock = { + color: string + icon: SkillCategoryIcon + id: string + name: string + officialName: string + size: number + skillsBreakdown: Array<{ name: string; percentage: number }> + totalMembers: number + totalSkills: number +} + +export type SkillMemberMock = { + countryCode: string + countryName: string + handle: string + name: string + photoURL?: string + rating: number + wins: number +} + +export const PROGRAMMING_CATEGORY_ID = '481b5ebc-2fe6-45ed-a90c-736936d458d7' + +export const SKILL_CATEGORIES: SkillCategoryMock[] = [ + { + color: '#1B4F72', + icon: IconOutline.TerminalIcon, + id: PROGRAMMING_CATEGORY_ID, + name: 'Programming & Development', + officialName: 'Programming and Development', + size: 10, + skillsBreakdown: [ + { name: 'JavaScript', percentage: 40 }, + { name: 'Python', percentage: 30 }, + { name: 'Swift', percentage: 15 }, + ], + totalMembers: 1012928, + totalSkills: 1059, + }, + { + color: '#3D8B8F', + icon: IconOutline.RssIcon, + id: 'cfb17211-2abd-41e1-b169-e90cf038c6a7', + name: 'Networking and Telecommunications', + officialName: 'Networking and Telecommunications', + size: 8.4, + skillsBreakdown: [ + { name: 'TCP/IP', percentage: 35 }, + { name: 'Routing', percentage: 28 }, + { name: '5G', percentage: 22 }, + ], + totalMembers: 412800, + totalSkills: 286, + }, + { + color: '#5B9BD5', + icon: IconOutline.GlobeAltIcon, + id: '5aadafad-da63-488e-8499-32b596215789', + name: 'Web Development', + officialName: 'Web Development', + size: 7.8, + skillsBreakdown: [ + { name: 'React', percentage: 38 }, + { name: 'Node.js', percentage: 27 }, + { name: 'CSS', percentage: 20 }, + ], + totalMembers: 388420, + totalSkills: 412, + }, + { + color: '#5EB3C4', + icon: IconOutline.ShieldCheckIcon, + id: '221f4e3f-1ac8-438b-9dc1-977e30656789', + name: 'Cybersecurity', + officialName: 'Cybersecurity', + size: 7.2, + skillsBreakdown: [ + { name: 'Pen Testing', percentage: 32 }, + { name: 'SIEM', percentage: 26 }, + { name: 'IAM', percentage: 24 }, + ], + totalMembers: 276540, + totalSkills: 198, + }, + { + color: '#1A3D3D', + icon: IconOutline.CloudIcon, + id: 'cc346829-c9e4-44a9-996b-34054cf20fec', + name: 'Cloud Computing', + officialName: 'Cloud Computing', + size: 6.4, + skillsBreakdown: [ + { name: 'AWS', percentage: 42 }, + { name: 'Azure', percentage: 28 }, + { name: 'GCP', percentage: 18 }, + ], + totalMembers: 241100, + totalSkills: 176, + }, + { + color: '#2D4A3E', + icon: IconOutline.RefreshIcon, + id: 'aa495f25-2f2d-4334-9b6f-2ffe11d835d2', + name: 'Software Development Lifecycle', + officialName: 'Software Development Lifecycle (SDLC)', + size: 6.2, + skillsBreakdown: [ + { name: 'Agile', percentage: 40 }, + { name: 'CI/CD', percentage: 30 }, + { name: 'Scrum', percentage: 18 }, + ], + totalMembers: 198760, + totalSkills: 94, + }, + { + color: '#4A5D4A', + icon: IconOutline.DuplicateIcon, + id: 'e2429c1b-7609-49e0-93cc-341a89e12269', + name: 'DevOps & Automation', + officialName: 'DevOps and Automation', + size: 5.8, + skillsBreakdown: [ + { name: 'Kubernetes', percentage: 34 }, + { name: 'Terraform', percentage: 28 }, + { name: 'Jenkins', percentage: 22 }, + ], + totalMembers: 176430, + totalSkills: 142, + }, + { + color: '#3D7EA6', + icon: IconOutline.ChipIcon, + id: '185f4bf3-50de-46af-aaa6-9011872395cf', + name: 'Operating Systems', + officialName: 'Operating Systems', + size: 5.5, + skillsBreakdown: [ + { name: 'Linux', percentage: 48 }, + { name: 'Windows', percentage: 22 }, + { name: 'macOS', percentage: 16 }, + ], + totalMembers: 154220, + totalSkills: 88, + }, + { + color: '#2C5F8A', + icon: IconOutline.ChartBarIcon, + id: '4064574c-befa-4fb3-a8e2-34038d7f845b', + name: 'Data Analysis & Big Data', + officialName: 'Data Analysis and Big Data', + size: 5.4, + skillsBreakdown: [ + { name: 'SQL', percentage: 36 }, + { name: 'Spark', percentage: 26 }, + { name: 'Tableau', percentage: 20 }, + ], + totalMembers: 148900, + totalSkills: 164, + }, + { + color: '#7EB8C4', + icon: IconOutline.SparklesIcon, + id: 'a1289278-a734-4523-918f-ea0f05667e24', + name: 'Machine Learning & AI', + officialName: 'Machine Learning and AI', + size: 5.1, + skillsBreakdown: [ + { name: 'PyTorch', percentage: 33 }, + { name: 'TensorFlow', percentage: 29 }, + { name: 'NLP', percentage: 21 }, + ], + totalMembers: 132450, + totalSkills: 210, + }, + { + color: '#2C4A6E', + icon: IconOutline.ServerIcon, + id: 'e50b1794-e08d-4dc3-a4b1-6b5213c7da8e', + name: 'Databases & Data Warehousing', + officialName: 'Databases and Data Warehousing', + size: 5, + skillsBreakdown: [ + { name: 'PostgreSQL', percentage: 34 }, + { name: 'Snowflake', percentage: 28 }, + { name: 'Redshift', percentage: 20 }, + ], + totalMembers: 121800, + totalSkills: 118, + }, + { + color: '#3D5C5C', + icon: IconOutline.PencilAltIcon, + id: '3eb5163c-cd3f-4c7d-b059-95c901bc2066', + name: 'UX Design & Multimedia', + officialName: 'User Experience Design and Multimedia', + size: 4.6, + skillsBreakdown: [ + { name: 'Figma', percentage: 42 }, + { name: 'UX Research', percentage: 24 }, + { name: 'Motion', percentage: 16 }, + ], + totalMembers: 98600, + totalSkills: 76, + }, + { + color: '#6B7C4A', + icon: IconOutline.CalculatorIcon, + id: 'b3c8970d-79e8-4f97-a84e-841aebaa890f', + name: 'Mathematics & Statistics', + officialName: 'Mathematics and Statistics', + size: 4.5, + skillsBreakdown: [ + { name: 'Statistics', percentage: 38 }, + { name: 'Linear Algebra', percentage: 27 }, + { name: 'R', percentage: 19 }, + ], + totalMembers: 87400, + totalSkills: 64, + }, + { + color: '#2D5A8A', + icon: IconOutline.CubeTransparentIcon, + id: '35e9e3c6-3480-4fdb-9f77-91e667923a01', + name: 'Virtualization', + officialName: 'Virtualization', + size: 4.4, + skillsBreakdown: [ + { name: 'VMware', percentage: 36 }, + { name: 'Hyper-V', percentage: 28 }, + { name: 'KVM', percentage: 20 }, + ], + totalMembers: 76210, + totalSkills: 41, + }, + { + color: '#5A8A8A', + icon: IconOutline.MapIcon, + id: '6b9717ef-9520-4507-9039-2acedbec002d', + name: 'Geospatial Information Systems', + officialName: 'Geospatial Information Systems (GIS)', + size: 4, + skillsBreakdown: [ + { name: 'ArcGIS', percentage: 40 }, + { name: 'QGIS', percentage: 28 }, + { name: 'GeoJSON', percentage: 18 }, + ], + totalMembers: 54120, + totalSkills: 52, + }, + { + color: '#4EC4C4', + icon: IconOutline.DesktopComputerIcon, + id: '831ed28d-c20f-40c8-a348-d1d3739e9046', + name: 'Hardware & Systems Administration', + officialName: 'Hardware and Systems Administration', + size: 3.9, + skillsBreakdown: [ + { name: 'Linux Admin', percentage: 36 }, + { name: 'Networking', percentage: 27 }, + { name: 'Hardware', percentage: 21 }, + ], + totalMembers: 49880, + totalSkills: 58, + }, + { + color: '#2C4A6E', + icon: IconOutline.ClipboardCheckIcon, + id: 'c5f83f60-4dcf-4305-b55e-b38fe5afec60', + name: 'Software Testing & QA', + officialName: 'Software Testing and Quality Assurance', + size: 3.8, + skillsBreakdown: [ + { name: 'Selenium', percentage: 34 }, + { name: 'Cypress', percentage: 28 }, + { name: 'JMeter', percentage: 20 }, + ], + totalMembers: 46750, + totalSkills: 72, + }, + { + color: '#5A8A9A', + icon: IconOutline.DatabaseIcon, + id: '38fadd80-8721-4ce0-9387-cb6ad3ce48da', + name: 'Database Management', + officialName: 'Database Management', + size: 3.7, + skillsBreakdown: [ + { name: 'MySQL', percentage: 38 }, + { name: 'Oracle', percentage: 26 }, + { name: 'MongoDB', percentage: 20 }, + ], + totalMembers: 43210, + totalSkills: 81, + }, + { + color: '#4A9A9A', + icon: IconOutline.DeviceMobileIcon, + id: '0ae22576-48ed-4ffd-9319-058b6fd80675', + name: 'Mobile App Development', + officialName: 'Mobile App Development', + size: 3.5, + skillsBreakdown: [ + { name: 'Swift', percentage: 32 }, + { name: 'Kotlin', percentage: 30 }, + { name: 'React Native', percentage: 22 }, + ], + totalMembers: 38940, + totalSkills: 96, + }, + { + color: '#3D6A8A', + icon: IconOutline.ShareIcon, + id: 'f1daa100-b63b-45c1-a638-90fbfc817200', + name: 'Blockchain', + officialName: 'Blockchain', + size: 3.3, + skillsBreakdown: [ + { name: 'Solidity', percentage: 40 }, + { name: 'Ethereum', percentage: 28 }, + { name: 'Web3', percentage: 18 }, + ], + totalMembers: 27650, + totalSkills: 44, + }, + { + color: '#5EB8B0', + icon: IconOutline.WifiIcon, + id: 'e4a51b10-ecba-46eb-89e2-5908bb324a8c', + name: 'IoT (Internet of Things)', + officialName: 'IoT (Internet of Things)', + size: 3.2, + skillsBreakdown: [ + { name: 'MQTT', percentage: 34 }, + { name: 'Embedded C', percentage: 28 }, + { name: 'Arduino', percentage: 22 }, + ], + totalMembers: 24180, + totalSkills: 39, + }, + { + color: '#3D5A6E', + icon: IconOutline.ClipboardListIcon, + id: '07a0abe3-2791-4068-b5b5-be48fefa3551', + name: 'Project Management', + officialName: 'Project Management', + size: 3.1, + skillsBreakdown: [ + { name: 'Jira', percentage: 36 }, + { name: 'PMP', percentage: 26 }, + { name: 'Kanban', percentage: 22 }, + ], + totalMembers: 21890, + totalSkills: 27, + }, + { + color: '#4A6A7A', + icon: IconOutline.CodeIcon, + id: '1f5ed3e8-8d22-44ea-b75d-ea85147a04da', + name: 'Scripting & Automation', + officialName: 'Scripting and Automation', + size: 3, + skillsBreakdown: [ + { name: 'Bash', percentage: 36 }, + { name: 'Python', percentage: 32 }, + { name: 'PowerShell', percentage: 18 }, + ], + totalMembers: 19640, + totalSkills: 33, + }, +] diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-members.mock.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-members.mock.ts new file mode 100644 index 000000000..9001c5cf1 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/mock/skill-members.mock.ts @@ -0,0 +1,192 @@ +import { + PROGRAMMING_CATEGORY_ID, + SkillMemberMock, + SKILL_CATEGORIES, +} from './skill-categories.mock' + +const COUNTRIES: Array<{ code: string; name: string }> = [ + { code: 'IN', name: 'India' }, + { code: 'US', name: 'USA' }, + { code: 'CN', name: 'China' }, + { code: 'GB', name: 'UK' }, + { code: 'UA', name: 'Ukraine' }, + { code: 'CA', name: 'Canada' }, + { code: 'BR', name: 'Brazil' }, + { code: 'DE', name: 'Germany' }, + { code: 'JP', name: 'Japan' }, + { code: 'AU', name: 'Australia' }, +] + +const HANDLES = [ + 'skywalker', 'bytecraft', 'codecat', 'pixelhawk', 'algomind', + 'devnova', 'stackpilot', 'nimbusdev', 'qubitron', 'hashlane', + 'loopsmith', 'gridfox', 'nullwave', 'bitforge', 'cloudnest', + 'syntaxio', 'datapath', 'kernelfox', 'vectorly', 'modulin', +] + +const FIRST_NAMES = [ + 'Alex', 'Jordan', 'Priya', 'Wei', 'Sofia', 'Noah', 'Amina', 'Lucas', 'Mei', 'Omar', +] + +const LAST_NAMES = [ + 'Chen', 'Patel', 'Nguyen', 'Garcia', 'Khan', 'Silva', 'Ivanov', 'Kim', 'Brown', 'Rossi', +] + +const RATINGS = [780, 950, 1100, 1350, 1480, 1600, 1800, 1900, 2000, 2100, 2200, 2300, 2400] + +const PROGRAMMING_TOP_MEMBERS: SkillMemberMock[] = [ + { + countryCode: 'IN', + countryName: 'India', + handle: 'billzedison', + name: 'Honghan W', + rating: 2000, + wins: 376, + }, + { + countryCode: 'US', + countryName: 'USA', + handle: 'Ghostar', + name: 'Justin G', + rating: 1900, + wins: 322, + }, + { + countryCode: 'IN', + countryName: 'India', + handle: 'stevenfrog', + name: 'Steven', + rating: 2100, + wins: 280, + }, + { + countryCode: 'CN', + countryName: 'China', + handle: 'ergolite', + name: 'Michael P', + rating: 2200, + wins: 262, + }, + { + countryCode: 'IN', + countryName: 'India', + handle: 'jiangliwu', + name: 'Jiang L', + rating: 950, + wins: 210, + }, + { + countryCode: 'GB', + countryName: 'UK', + handle: 'diazx', + name: 'DAT N', + rating: 2300, + wins: 200, + }, + { + countryCode: 'US', + countryName: 'USA', + handle: 'Standlove', + name: 'GuanZhao I', + rating: 1800, + wins: 188, + }, + { + countryCode: 'IN', + countryName: 'India', + handle: 'soso0574', + name: 'Jianchang S', + rating: 1600, + wins: 176, + }, + { + countryCode: 'IN', + countryName: 'India', + handle: 'vasilica.olaru', + name: 'vasilica.olaru', + rating: 2400, + wins: 132, + }, + { + countryCode: 'IN', + countryName: 'India', + handle: 'ngoctay', + name: 'Minh Ngoc P', + rating: 780, + wins: 90, + }, +] + +function hashString(value: string): number { + let hash = 0 + for (let index = 0; index < value.length; index += 1) { + hash = ((hash * 31) + value.charCodeAt(index)) % 2147483647 + } + + return Math.abs(hash) +} + +function buildGeneratedMembers(categoryId: string, startWins: number): SkillMemberMock[] { + const members: SkillMemberMock[] = [] + + for (let index = 0; index < 90; index += 1) { + const seed = hashString(`${categoryId}-${index}`) + const country = COUNTRIES[seed % COUNTRIES.length] + const firstName = FIRST_NAMES[seed % FIRST_NAMES.length] + const lastName = LAST_NAMES[hashString(`${categoryId}-last-${index}`) % LAST_NAMES.length] + const handleBase = HANDLES[hashString(`${categoryId}-handle-${index}`) % HANDLES.length] + + members.push({ + countryCode: country.code, + countryName: country.name, + handle: `${handleBase}${index + 1}`, + name: `${firstName} ${lastName.charAt(0)}`, + rating: RATINGS[seed % RATINGS.length], + wins: Math.max(1, startWins - index), + }) + } + + return members +} + +function buildMembersForCategory(categoryId: string): SkillMemberMock[] { + if (categoryId === PROGRAMMING_CATEGORY_ID) { + return [ + ...PROGRAMMING_TOP_MEMBERS, + ...buildGeneratedMembers(categoryId, 89), + ] + } + + const seed = hashString(categoryId) + const startWins = 120 + (seed % 80) + + return buildGeneratedMembers(categoryId, startWins) + .slice(0, 100) + .sort((left, right) => right.wins - left.wins) +} + +export const SKILL_MEMBERS_BY_CATEGORY: Record = Object.fromEntries( + SKILL_CATEGORIES.map(category => [ + category.id, + buildMembersForCategory(category.id), + ]), +) + +export function getMembersForCategory(categoryId: string): SkillMemberMock[] { + return SKILL_MEMBERS_BY_CATEGORY[categoryId] || [] +} + +export function getTopMemberForCategory(categoryId: string): SkillMemberMock | undefined { + if (categoryId === PROGRAMMING_CATEGORY_ID) { + return { + countryCode: 'IN', + countryName: 'India', + handle: 'banerjeesourish', + name: 'Sourish Banerjee', + rating: 1400, + wins: 1768, + } + } + + return getMembersForCategory(categoryId)[0] +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts new file mode 100644 index 000000000..c9cceac3a --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/SkillStatisticsPage/packCircles.ts @@ -0,0 +1,137 @@ +export type PackedCircle = { + id: string + r: number + x: number + y: number +} + +function overlaps( + a: PackedCircle, + b: PackedCircle, + padding: number, +): boolean { + const dx = a.x - b.x + const dy = a.y - b.y + const minDist = a.r + b.r + padding + + return (dx * dx) + (dy * dy) < minDist * minDist +} + +function hashString(value: string): number { + let hash = 0 + + for (let i = 0; i < value.length; i += 1) { + hash = ((hash * 31) + value.charCodeAt(i)) % 2147483647 + } + + return hash + 1 +} + +function createRng(seed: number): () => number { + let state = (seed % 2147483646) + 1 + + return () => { + state = (state * 16807) % 2147483647 + return (state - 1) / 2147483646 + } +} + +function shuffleItems(items: T[], rng: () => number): T[] { + const shuffled = [...items] + + for (let i = shuffled.length - 1; i > 0; i -= 1) { + const j = Math.floor(rng() * (i + 1)) + const current = shuffled[i] + shuffled[i] = shuffled[j] + shuffled[j] = current + } + + return shuffled +} + +/** + * Place circles in a tight non-overlapping cluster, then scale uniformly + * so the pack fits inside the given viewport while staying close together. + * Placement order is shuffled so the largest bubble is not always centered. + */ +export function packCircles( + items: Array<{ id: string; r: number }>, + width: number, + height: number, + padding: number = 6, +): PackedCircle[] { + const rng = createRng(items.reduce((seed, item) => seed + hashString(item.id), 1)) + const ordered = shuffleItems(items, rng) + const placed: PackedCircle[] = [] + const aspect = Math.min(Math.max(width / Math.max(height, 1), 1), 2.15) + const angleOffset = rng() * Math.PI * 2 + + ordered.forEach(item => { + if (placed.length === 0) { + placed.push({ + id: item.id, + r: item.r, + x: 0, + y: 0, + }) + return + } + + let found: PackedCircle | undefined + const maxReach = placed.reduce( + (reach, circle) => Math.max( + reach, + Math.hypot(circle.x / aspect, circle.y) + circle.r, + ), + 0, + ) + item.r + padding + 8 + + for (let dist = item.r; dist <= maxReach && !found; dist += 3) { + const steps = Math.max(16, Math.ceil((2 * Math.PI * dist) / 10)) + for (let step = 0; step < steps && !found; step += 1) { + const angle = ((step / steps) * 2 * Math.PI) + angleOffset + (placed.length * 0.37) + const candidate: PackedCircle = { + id: item.id, + r: item.r, + x: Math.cos(angle) * dist * aspect, + y: Math.sin(angle) * dist, + } + + if (!placed.some(circle => overlaps(candidate, circle, padding))) { + found = candidate + } + } + } + + placed.push(found || { + id: item.id, + r: item.r, + x: (maxReach + item.r) * aspect, + y: 0, + }) + }) + + if (!placed.length || width <= 0 || height <= 0) { + return placed + } + + const minX = Math.min(...placed.map(circle => circle.x - circle.r)) + const maxX = Math.max(...placed.map(circle => circle.x + circle.r)) + const minY = Math.min(...placed.map(circle => circle.y - circle.r)) + const maxY = Math.max(...placed.map(circle => circle.y + circle.r)) + const packWidth = Math.max(maxX - minX, 1) + const packHeight = Math.max(maxY - minY, 1) + const inset = 16 + const availableWidth = Math.max(width - (inset * 2), 1) + const availableHeight = Math.max(height - (inset * 2), 1) + const scale = Math.min(availableWidth / packWidth, availableHeight / packHeight) + const offsetX = inset + ((availableWidth - (packWidth * scale)) / 2) + const offsetY = inset + ((availableHeight - (packHeight * scale)) / 2) + + return placed.map(circle => ({ + id: circle.id, + r: circle.r * scale, + x: ((circle.x - minX) * scale) + offsetX, + y: ((circle.y - minY) * scale) + offsetY, + })) +} diff --git a/src/apps/customer-portal/src/pages/skill-statistics/skill-statistics.routes.tsx b/src/apps/customer-portal/src/pages/skill-statistics/skill-statistics.routes.tsx new file mode 100644 index 000000000..49948f976 --- /dev/null +++ b/src/apps/customer-portal/src/pages/skill-statistics/skill-statistics.routes.tsx @@ -0,0 +1,26 @@ +import { getRoutesContainer, lazyLoad, LazyLoadedComponent } from '~/libs/core' + +import { skillStatisticsRouteId } from '../../config/routes.config' + +const SkillStatisticsPage: LazyLoadedComponent = lazyLoad( + () => import('./SkillStatisticsPage'), + 'SkillStatisticsPage', +) + +export const skillStatisticsChildRoutes = [ + { + authRequired: true, + element: , + id: 'skill-statistics-page', + route: '', + }, +] + +export const customerPortalSkillStatisticsRoutes = [ + { + children: [...skillStatisticsChildRoutes], + element: getRoutesContainer(skillStatisticsChildRoutes), + id: skillStatisticsRouteId, + route: skillStatisticsRouteId, + }, +]