kyd-shared-badge 0.3.99 → 0.3.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/SharedBadgeDisplay.tsx +17 -15
- package/src/components/GaugeCard.tsx +35 -11
- package/src/components/ReportHeader.tsx +22 -6
- package/src/components/RiskCard.tsx +12 -0
- package/src/components/RoleOverviewCard.tsx +44 -21
- package/src/components/SkillsBubble.tsx +156 -61
- package/src/connect/ConnectAccounts.tsx +5 -3
- package/src/types.ts +11 -1
package/package.json
CHANGED
|
@@ -211,7 +211,7 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
|
|
|
211
211
|
{/* Quadrant layout */}
|
|
212
212
|
<div className={'grid grid-cols-1 md:grid-cols-2 gap-6'}>
|
|
213
213
|
{/* Top-left: Name, countries, badge image handled by ReportHeader */}
|
|
214
|
-
<div>
|
|
214
|
+
<div className={`${hasEnterpriseMatch ? '' : 'md:col-span-2'}`}>
|
|
215
215
|
<ReportHeader
|
|
216
216
|
badgeId={badgeId}
|
|
217
217
|
developerName={badgeData.developerName}
|
|
@@ -227,20 +227,22 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
|
|
|
227
227
|
/>
|
|
228
228
|
</div>
|
|
229
229
|
{/* Top-right: Role match section */}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
230
|
+
{hasEnterpriseMatch && (
|
|
231
|
+
<div>
|
|
232
|
+
{(() => {
|
|
233
|
+
const em = assessmentResult?.enterprise_match;
|
|
234
|
+
if (!em) return null;
|
|
235
|
+
const role = em.role || {};
|
|
236
|
+
return (
|
|
237
|
+
<RoleOverviewCard
|
|
238
|
+
title={'Role Alignment'}
|
|
239
|
+
matchLabel={em.label}
|
|
240
|
+
roleName={role?.name || 'Role'}
|
|
241
|
+
/>
|
|
242
|
+
);
|
|
243
|
+
})()}
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
244
246
|
|
|
245
247
|
{/* Bottom-left: Technical score */}
|
|
246
248
|
<div>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React from 'react';
|
|
3
|
+
import React, { useMemo } from 'react';
|
|
4
4
|
import GaugeComponent from '@knowyourdeveloper/react-gauge-component';
|
|
5
5
|
import BusinessRuleLink from './BusinessRuleLink';
|
|
6
6
|
import { FiInfo } from 'react-icons/fi';
|
|
7
|
-
import { hexToRgba, scoreToColorHex,
|
|
7
|
+
import { hexToRgba, scoreToColorHex, clampPercent, red, yellow, green } from '../colors';
|
|
8
8
|
|
|
9
9
|
type TopMover = { label?: string; uid?: string };
|
|
10
10
|
|
|
@@ -29,20 +29,43 @@ export default function GaugeCard({
|
|
|
29
29
|
}) {
|
|
30
30
|
const pct = clampPercent(percent);
|
|
31
31
|
const displayLabel = label || '';
|
|
32
|
-
// Keep card tinting consistent with score
|
|
33
|
-
const progressColor = scoreToCssVar(pct);
|
|
34
32
|
const headerTint = hexToRgba(scoreToColorHex(pct), 0.06);
|
|
35
33
|
|
|
34
|
+
// Technical evidence tiers from backend thresholds
|
|
35
|
+
const tickLabels = useMemo(() => ({
|
|
36
|
+
type: 'outer' as const,
|
|
37
|
+
hideMinMax: false,
|
|
38
|
+
defaultTickLineConfig: {
|
|
39
|
+
length: 7,
|
|
40
|
+
width: 1,
|
|
41
|
+
distanceFromArc: 3,
|
|
42
|
+
color: 'var(--icon-button-secondary)'
|
|
43
|
+
},
|
|
44
|
+
defaultTickValueConfig: {
|
|
45
|
+
style: {
|
|
46
|
+
fontSize: '10px',
|
|
47
|
+
fill: 'var(--text-secondary)'
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
ticks: [
|
|
51
|
+
{ value: 0, valueConfig: { formatTextValue: () => 'Very Low' } },
|
|
52
|
+
{ value: 25, valueConfig: { formatTextValue: () => 'Low' } },
|
|
53
|
+
{ value: 50, valueConfig: { formatTextValue: () => 'Moderate' } },
|
|
54
|
+
{ value: 75, valueConfig: { formatTextValue: () => 'High' } },
|
|
55
|
+
{ value: 100, valueConfig: { formatTextValue: () => 'Very High' } },
|
|
56
|
+
]
|
|
57
|
+
}), []);
|
|
58
|
+
|
|
36
59
|
return (
|
|
37
60
|
<div
|
|
38
|
-
className={'rounded-md
|
|
61
|
+
className={'rounded-md border flex flex-col min-h-full'}
|
|
39
62
|
style={{
|
|
40
63
|
backgroundColor: 'var(--content-card-background)',
|
|
41
64
|
borderColor: 'var(--icon-button-secondary)',
|
|
42
65
|
backgroundImage: `linear-gradient(${headerTint}, ${headerTint})`,
|
|
43
66
|
}}
|
|
44
67
|
>
|
|
45
|
-
<div className="mb-3 flex items-start justify-between gap-2">
|
|
68
|
+
<div className="mb-3 flex items-start justify-between gap-2 pt-5 px-5">
|
|
46
69
|
<div>
|
|
47
70
|
<div className={'font-semibold'} style={{ color: 'var(--text-main)' }}>{title}</div>
|
|
48
71
|
{description ? (
|
|
@@ -63,19 +86,21 @@ export default function GaugeCard({
|
|
|
63
86
|
)}
|
|
64
87
|
</div>
|
|
65
88
|
<div className="flex-grow flex flex-col items-center justify-center" style={{ minHeight: 200 }}>
|
|
66
|
-
<div className="relative group" style={{ width: '100%', aspectRatio: '2 / 1', maxWidth:
|
|
89
|
+
<div className="relative group" style={{ width: '100%', aspectRatio: '2 / 1', maxWidth: 390 }}>
|
|
67
90
|
<GaugeComponent
|
|
68
91
|
type="semicircle"
|
|
69
|
-
style={{ width: '100%', height: '100%' }}
|
|
92
|
+
style={{ width: 'calc(100% - 16px)', height: '100%', marginLeft: 8, marginRight: 8 }}
|
|
93
|
+
marginInPercent={{ top: 0.08, bottom: 0.0, left: 0.1, right: 0.1 }}
|
|
70
94
|
value={pct}
|
|
71
95
|
minValue={0}
|
|
72
96
|
maxValue={100}
|
|
73
97
|
labels={{
|
|
74
98
|
valueLabel: {
|
|
75
|
-
//
|
|
76
|
-
formatTextValue: () =>
|
|
99
|
+
// Hide center text; show tier labels around the arc instead
|
|
100
|
+
formatTextValue: () => '',
|
|
77
101
|
matchColorWithArc: true,
|
|
78
102
|
},
|
|
103
|
+
tickLabels: tickLabels,
|
|
79
104
|
}}
|
|
80
105
|
arc={{
|
|
81
106
|
padding: 0.02,
|
|
@@ -98,7 +123,6 @@ export default function GaugeCard({
|
|
|
98
123
|
</div>
|
|
99
124
|
)}
|
|
100
125
|
</div>
|
|
101
|
-
<div className={'mt-3 text-sm font-semibold'} style={{ color: 'var(--text-main)' }}>{displayLabel}</div>
|
|
102
126
|
</div>
|
|
103
127
|
{Array.isArray(topMovers) && topMovers.length > 0 && (
|
|
104
128
|
<div className="mt-4 text-center">
|
|
@@ -51,7 +51,7 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
|
|
|
51
51
|
|
|
52
52
|
return (
|
|
53
53
|
<div
|
|
54
|
-
className={'
|
|
54
|
+
className={'p-6 rounded-md shadow-md border'}
|
|
55
55
|
style={{ backgroundColor: 'var(--content-card-background)', borderColor: 'var(--icon-button-secondary)', backgroundImage: `linear-gradient(${tint}, ${tint})` }}
|
|
56
56
|
>
|
|
57
57
|
{(() => {
|
|
@@ -73,14 +73,18 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
|
|
|
73
73
|
</div>
|
|
74
74
|
);
|
|
75
75
|
})()}
|
|
76
|
-
<div className="flex flex-col gap-
|
|
76
|
+
<div className="flex flex-col gap-3">
|
|
77
77
|
{/* Info section: Title, Candidate, Details and Summary */}
|
|
78
78
|
<div className="w-full">
|
|
79
79
|
<div className='space-y-2'>
|
|
80
80
|
<span className='flex gap-2 w-full items-end text-start justify-start'>
|
|
81
|
-
<h2 className={'text-xl font-light'} style={{ color: 'var(--text-main)' }}>KYD Candidate
|
|
81
|
+
<h2 className={'text-xl font-light'} style={{ color: 'var(--text-main)' }}>KYD Candidate:</h2>
|
|
82
82
|
<div className={'text-xl font-bold'} style={{ color: 'var(--text-main)' }}>{developerName || 'N/A'}</div>
|
|
83
83
|
</span>
|
|
84
|
+
<span className='flex gap-2 w-full items-end text-start justify-start'>
|
|
85
|
+
<h3 className='text-sm font-semibold text-[--text-secondary]'>Date:</h3>
|
|
86
|
+
<span className='text-sm text-[--text-main]'>{formatLocalDate(updatedAt)}</span>
|
|
87
|
+
</span>
|
|
84
88
|
<div className={'text-sm'}>
|
|
85
89
|
{Array.isArray(countries) && countries.length > 0 && (
|
|
86
90
|
(() => {
|
|
@@ -88,9 +92,21 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
|
|
|
88
92
|
.map(code => countriesLib.getName((code || '').toUpperCase(), 'en') || code)
|
|
89
93
|
.filter(Boolean);
|
|
90
94
|
return (
|
|
91
|
-
<p>
|
|
92
|
-
<span className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>
|
|
95
|
+
<p className={'flex items-center gap-1'}>
|
|
96
|
+
<span className={'font-semibold'} style={{ color: 'var(--text-secondary)' }}>
|
|
97
|
+
{countryNames.length > 1 ? 'Countries:' : 'Country:'}
|
|
98
|
+
</span>
|
|
99
|
+
|
|
93
100
|
<span style={{ color: 'var(--text-main)' }}>{countryNames.join(', ')}</span>
|
|
101
|
+
<span className={'relative inline-flex items-center group cursor-help'} style={{ color: 'var(--text-secondary)' }}>
|
|
102
|
+
<FiInfo size={14} />
|
|
103
|
+
<div className="hidden group-hover:block absolute z-30 left-1/2 -translate-x-1/2 top-full mt-2 w-80">
|
|
104
|
+
<div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
|
|
105
|
+
<div style={{ fontWeight: 600 }}>Countries</div>
|
|
106
|
+
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-secondary)' }}>Derived from linked sources and activity; shows where they’re most affiliated.</div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</span>
|
|
94
110
|
</p>
|
|
95
111
|
);
|
|
96
112
|
})()
|
|
@@ -135,7 +151,7 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
|
|
|
135
151
|
{/* Badge Image with robust centered overlay */}
|
|
136
152
|
<div className="w-full flex items-center justify-center self-stretch">
|
|
137
153
|
<div className="relative w-full max-w-xs select-none">
|
|
138
|
-
<Image src={finalBadgeImageUrl} alt="KYD Badge" width={
|
|
154
|
+
<Image src={finalBadgeImageUrl} alt="KYD Badge" width={100} height={100} priority className='w-full h-auto pointer-events-none p-10'/>
|
|
139
155
|
<div className="pointer-events-none absolute left-1/2 top-[66%] -translate-x-1/2 -translate-y-1/2">
|
|
140
156
|
<div className="font-extrabold text-black text-3xl ">
|
|
141
157
|
{Math.round(score || 0)}%
|
|
@@ -89,6 +89,18 @@ export default function RiskCard({
|
|
|
89
89
|
}}
|
|
90
90
|
/>
|
|
91
91
|
))}
|
|
92
|
+
<span
|
|
93
|
+
className="pointer-events-none absolute left-[-65px] bottom-0 text-xs"
|
|
94
|
+
style={{ color: 'var(--text-secondary)' }}
|
|
95
|
+
>
|
|
96
|
+
Low risk -
|
|
97
|
+
</span>
|
|
98
|
+
<span
|
|
99
|
+
className="pointer-events-none absolute right-[-68px] top-0 text-xs"
|
|
100
|
+
style={{ color: 'var(--text-secondary)' }}
|
|
101
|
+
>
|
|
102
|
+
- High Risk
|
|
103
|
+
</span>
|
|
92
104
|
{(tooltipText || description) && (
|
|
93
105
|
<div className="hidden group-hover:block absolute z-30 left-1/2 -translate-x-1/2 top-full mt-2 w-80">
|
|
94
106
|
<div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React, { useMemo } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { FiInfo } from 'react-icons/fi';
|
|
4
6
|
import GaugeComponent from '@knowyourdeveloper/react-gauge-component';
|
|
5
7
|
import { clampPercent, hexToRgba, scoreToColorHex, red, yellow, green } from '../colors';
|
|
6
8
|
|
|
@@ -27,17 +29,33 @@ export default function RoleOverviewCard({
|
|
|
27
29
|
const headerTint = hexToRgba(scoreToColorHex(pct), 0.06);
|
|
28
30
|
const displayLabel = String(matchLabel || '').trim();
|
|
29
31
|
|
|
30
|
-
const
|
|
31
|
-
'
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
const tickLabels = useMemo(() => ({
|
|
33
|
+
type: 'outer' as const,
|
|
34
|
+
hideMinMax: false,
|
|
35
|
+
defaultTickLineConfig: {
|
|
36
|
+
length: 7,
|
|
37
|
+
width: 1,
|
|
38
|
+
distanceFromArc: 3,
|
|
39
|
+
color: 'var(--icon-button-secondary)'
|
|
40
|
+
},
|
|
41
|
+
defaultTickValueConfig: {
|
|
42
|
+
style: {
|
|
43
|
+
fontSize: '10px',
|
|
44
|
+
fill: 'var(--text-secondary)'
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
ticks: [
|
|
48
|
+
{ value: 0, valueConfig: { formatTextValue: () => 'Incompatible' } },
|
|
49
|
+
{ value: 25, valueConfig: { formatTextValue: () => 'Weak' } },
|
|
50
|
+
{ value: 50, valueConfig: { formatTextValue: () => 'Partial' } },
|
|
51
|
+
{ value: 75, valueConfig: { formatTextValue: () => 'Strong' } },
|
|
52
|
+
{ value: 100, valueConfig: { formatTextValue: () => 'Optimal' } },
|
|
53
|
+
]
|
|
54
|
+
}), []);
|
|
37
55
|
|
|
38
56
|
return (
|
|
39
57
|
<div
|
|
40
|
-
className={'rounded-md p-5 border flex flex-col
|
|
58
|
+
className={'rounded-md p-5 border flex flex-col h-full'}
|
|
41
59
|
style={{
|
|
42
60
|
backgroundColor: 'var(--content-card-background)',
|
|
43
61
|
borderColor: 'var(--icon-button-secondary)',
|
|
@@ -47,16 +65,24 @@ export default function RoleOverviewCard({
|
|
|
47
65
|
<div className="mb-3 flex items-start justify-between gap-2">
|
|
48
66
|
<div>
|
|
49
67
|
<div className={'font-semibold'} style={{ color: 'var(--text-main)' }}>{title}</div>
|
|
50
|
-
{
|
|
51
|
-
<div className={'text-xs mt-1'} style={{ color: 'var(--text-secondary)' }}>{roleName}</div>
|
|
52
|
-
) : null}
|
|
68
|
+
<div className={'text-xs mt-1'} style={{ color: 'var(--text-secondary)' }}>How well the candidate aligns with the target role based on KYD evidence.</div>
|
|
53
69
|
</div>
|
|
70
|
+
<span className={'relative inline-flex items-center group cursor-help'} style={{ color: 'var(--text-secondary)' }}>
|
|
71
|
+
<FiInfo />
|
|
72
|
+
<div className="hidden group-hover:block absolute z-30 right-0 top-full mt-2 w-80">
|
|
73
|
+
<div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
|
|
74
|
+
<div style={{ fontWeight: 600 }}>{title}</div>
|
|
75
|
+
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>Alignment reflects how well the candidate matches the target role based on KYD evidence.</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</span>
|
|
54
79
|
</div>
|
|
55
80
|
<div className="flex-grow flex flex-col items-center justify-center" style={{ minHeight: 200 }}>
|
|
56
81
|
<div className="relative" style={{ width: '100%', aspectRatio: '2 / 1', maxWidth: 360 }}>
|
|
57
82
|
<GaugeComponent
|
|
58
83
|
type="semicircle"
|
|
59
|
-
style={{ width: '100%', height: '100%' }}
|
|
84
|
+
style={{ width: 'calc(100% - 16px)', height: '100%', marginLeft: 8, marginRight: 8 }}
|
|
85
|
+
marginInPercent={{ top: 0.08, bottom: 0.0, left: 0.1, right: 0.1 }}
|
|
60
86
|
value={pct}
|
|
61
87
|
minValue={0}
|
|
62
88
|
maxValue={100}
|
|
@@ -65,6 +91,7 @@ export default function RoleOverviewCard({
|
|
|
65
91
|
formatTextValue: () => displayLabel,
|
|
66
92
|
matchColorWithArc: true,
|
|
67
93
|
},
|
|
94
|
+
tickLabels: tickLabels,
|
|
68
95
|
}}
|
|
69
96
|
arc={{
|
|
70
97
|
padding: 0.02,
|
|
@@ -74,17 +101,13 @@ export default function RoleOverviewCard({
|
|
|
74
101
|
pointer={{ type: 'arrow', elastic: true, animationDelay: 0 }}
|
|
75
102
|
/>
|
|
76
103
|
</div>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
<div key={idx} className="flex flex-col items-center">
|
|
81
|
-
<div className="h-2 w-px" style={{ background: 'var(--icon-button-secondary)' }} />
|
|
82
|
-
<div className="mt-1 whitespace-nowrap">{label}</div>
|
|
83
|
-
</div>
|
|
84
|
-
))}
|
|
85
|
-
</div>
|
|
104
|
+
{roleName ? (
|
|
105
|
+
<div className="mt-3 text-center">
|
|
106
|
+
<Link href="#role" className={'text-sm font-semibold text-[var(--text-main)] hover:underline'}>{roleName}</Link>
|
|
86
107
|
</div>
|
|
108
|
+
) : null}
|
|
87
109
|
</div>
|
|
110
|
+
|
|
88
111
|
</div>
|
|
89
112
|
);
|
|
90
113
|
}
|
|
@@ -13,6 +13,8 @@ type SkillsRadarPoint = {
|
|
|
13
13
|
self_reported?: number;
|
|
14
14
|
certified?: number;
|
|
15
15
|
experience?: number; // 0-100 saturation driver
|
|
16
|
+
// Total evidence count (backend computed) per category
|
|
17
|
+
evidence_count_total?: number;
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
type HoverTooltipState = {
|
|
@@ -20,7 +22,7 @@ type HoverTooltipState = {
|
|
|
20
22
|
x: number;
|
|
21
23
|
y: number;
|
|
22
24
|
title: string;
|
|
23
|
-
body?:
|
|
25
|
+
body?: React.ReactNode;
|
|
24
26
|
} | null;
|
|
25
27
|
|
|
26
28
|
const TooltipBox = ({ state }: { state: HoverTooltipState }) => {
|
|
@@ -50,7 +52,7 @@ const TooltipBox = ({ state }: { state: HoverTooltipState }) => {
|
|
|
50
52
|
};
|
|
51
53
|
|
|
52
54
|
|
|
53
|
-
export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, skillsMeta, headless }: { skillsCategoryRadar?: SkillsRadarPoint[]; skillsByCategory?: Record<string, string[]>; skillsMeta?: Record<string, { presence?: 'certified' | 'observed' | 'self-reported'; years?: number; sources?: string[] }>; headless?: boolean }) {
|
|
55
|
+
export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, skillsMeta, headless }: { skillsCategoryRadar?: SkillsRadarPoint[]; skillsByCategory?: Record<string, string[]>; skillsMeta?: Record<string, { presence?: 'certified' | 'observed' | 'self-reported'; presenceTypes?: Array<'certified' | 'observed' | 'self-reported'>; years?: number; sources?: string[]; evidenceCount?: number }>; headless?: boolean }) {
|
|
54
56
|
const hasRadar = !!(skillsCategoryRadar && skillsCategoryRadar.length > 0);
|
|
55
57
|
const skillsRadarLimited = (skillsCategoryRadar || []).slice(0, 24);
|
|
56
58
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -58,6 +60,7 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
|
|
|
58
60
|
const [legendTooltip, setLegendTooltip] = useState<HoverTooltipState>(null);
|
|
59
61
|
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
|
60
62
|
const [legendHovered, setLegendHovered] = useState<boolean>(false);
|
|
63
|
+
|
|
61
64
|
useEffect(() => {
|
|
62
65
|
if (typeof window !== 'undefined') {
|
|
63
66
|
const id = window.setTimeout(() => {
|
|
@@ -77,14 +80,14 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
|
|
|
77
80
|
const y = rect.bottom - hostRect.top + 6;
|
|
78
81
|
return { x, y };
|
|
79
82
|
};
|
|
80
|
-
const showLegendTooltipAt = (target: EventTarget | null, title: string, body?:
|
|
83
|
+
const showLegendTooltipAt = (target: EventTarget | null, title: string, body?: React.ReactNode) => {
|
|
81
84
|
if (!(target instanceof HTMLElement)) return;
|
|
82
85
|
const { x, y } = computeTooltipPosition(target);
|
|
83
86
|
setLegendTooltip({ visible: true, x, y, title, body });
|
|
84
87
|
};
|
|
85
88
|
const hideLegendTooltip = () => setLegendTooltip(null);
|
|
86
89
|
|
|
87
|
-
// ratio drives size
|
|
90
|
+
// ratio drives size by default; prefer backend evidence_count_total when present
|
|
88
91
|
const bubbles = useMemo(() => {
|
|
89
92
|
const seriesAvg = (d: SkillsRadarPoint): number => {
|
|
90
93
|
const vals = [Number(d.observed || 0), Number(d.self_reported || 0), Number(d.certified || 0)];
|
|
@@ -93,10 +96,19 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
|
|
|
93
96
|
return Math.max(0, Math.min(100, Math.round(base.reduce((a, b) => a + b, 0) / (base.length || 1))));
|
|
94
97
|
};
|
|
95
98
|
|
|
96
|
-
|
|
99
|
+
// Prefer evidence_count_total across categories if available; fallback to ratio
|
|
100
|
+
const evidenceTotals = (skillsCategoryRadar || []).map((d) => Number((d as any).evidence_count_total || 0));
|
|
101
|
+
const evidenceAvailable = evidenceTotals.some((v) => v > 0);
|
|
102
|
+
|
|
103
|
+
const maxValue = evidenceAvailable
|
|
104
|
+
? Math.max(1, ...evidenceTotals)
|
|
105
|
+
: Math.max(1, ...skillsRadarLimited.map(seriesAvg));
|
|
97
106
|
|
|
98
107
|
return skillsRadarLimited.map((d) => {
|
|
99
|
-
const
|
|
108
|
+
const ratio = seriesAvg(d);
|
|
109
|
+
const evidenceCountTotal = Number((d as any).evidence_count_total || 0);
|
|
110
|
+
const valueRaw = evidenceAvailable ? evidenceCountTotal : ratio;
|
|
111
|
+
const value = Math.max(0, Number(valueRaw));
|
|
100
112
|
const experience = Math.max(0, Math.min(100, Number(d.experience || 0)));
|
|
101
113
|
const size = Math.max(2, Math.round((value / maxValue) * 100)); // 2..100
|
|
102
114
|
const color = 'var(--content-card-background)';
|
|
@@ -104,11 +116,13 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
|
|
|
104
116
|
label: d.axis,
|
|
105
117
|
value: size,
|
|
106
118
|
color,
|
|
107
|
-
tooltip:
|
|
108
|
-
|
|
119
|
+
tooltip: evidenceAvailable
|
|
120
|
+
? `${d.axis}\nEvidence: ${value} sources\nExperience: ${experience}`
|
|
121
|
+
: `${d.axis}\nRatio: ${ratio}\nExperience: ${experience}`,
|
|
122
|
+
data: { ratio, experience, evidence: value }
|
|
109
123
|
};
|
|
110
124
|
});
|
|
111
|
-
}, [skillsRadarLimited]);
|
|
125
|
+
}, [skillsRadarLimited, skillsCategoryRadar]);
|
|
112
126
|
|
|
113
127
|
const bubbleData = useMemo(() => {
|
|
114
128
|
return bubbles.map((b) => ({
|
|
@@ -144,15 +158,16 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
|
|
|
144
158
|
const enriched = list.map((name) => {
|
|
145
159
|
const meta = skillsMeta?.[name] || {};
|
|
146
160
|
const sources = Array.isArray((meta as any).sources) ? (meta as any).sources as string[] : [];
|
|
147
|
-
|
|
161
|
+
const presenceTypes = Array.isArray((meta as any).presenceTypes) ? ((meta as any).presenceTypes as Array<'certified' | 'observed' | 'self-reported'>) : ((meta as any).presence ? [String((meta as any).presence) as any] : []);
|
|
162
|
+
return { name, years: Number(meta.years || 0), presence: (meta as any).presence as string | undefined, presenceTypes, sources };
|
|
148
163
|
}).sort((a, b) => b.years - a.years || a.name.localeCompare(b.name));
|
|
149
164
|
const items = enriched.slice(0, 10);
|
|
150
165
|
const overflow = list.length - items.length;
|
|
151
|
-
const display: Array<{ label: string; years?: number; presence?: string; sources?: string[] } | ''> = [];
|
|
166
|
+
const display: Array<{ label: string; years?: number; presence?: string; presenceTypes?: Array<'certified' | 'observed' | 'self-reported'>; sources?: string[] } | ''> = [];
|
|
152
167
|
for (let i = 0; i < Math.min(9, items.length); i++) {
|
|
153
168
|
const it = items[i];
|
|
154
169
|
const label = it.name ? `${it.name}` : '';
|
|
155
|
-
display.push({ label, years: it.years, presence: it.presence, sources: it.sources });
|
|
170
|
+
display.push({ label, years: it.years, presence: it.presence, presenceTypes: it.presenceTypes, sources: it.sources });
|
|
156
171
|
}
|
|
157
172
|
if (list.length > 10) {
|
|
158
173
|
// Aggregate years and sources for remaining datapoints beyond the top 10
|
|
@@ -168,11 +183,11 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
|
|
|
168
183
|
display.push({ label: `Others (${overflow})`, years: aggregatedYears, sources: aggregatedSources });
|
|
169
184
|
} else if (items.length >= 10) {
|
|
170
185
|
const it = items[9];
|
|
171
|
-
display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, sources: it?.sources });
|
|
186
|
+
display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, presenceTypes: it?.presenceTypes, sources: it?.sources });
|
|
172
187
|
} else {
|
|
173
188
|
if (items.length > 9) {
|
|
174
189
|
const it = items[9];
|
|
175
|
-
display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, sources: it?.sources });
|
|
190
|
+
display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, presenceTypes: it?.presenceTypes, sources: it?.sources });
|
|
176
191
|
}
|
|
177
192
|
}
|
|
178
193
|
while (display.length < 10) display.push('');
|
|
@@ -193,17 +208,41 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
|
|
|
193
208
|
const presenceColor = (presence?: string) => {
|
|
194
209
|
const p = String(presence || '').toLowerCase();
|
|
195
210
|
if (p === 'self-reported') return green5;
|
|
196
|
-
if (p === 'observed') return
|
|
211
|
+
if (p === 'observed') return green2;
|
|
197
212
|
if (p === 'certified') return green1;
|
|
198
213
|
return 'var(--icon-button-secondary)';
|
|
199
214
|
};
|
|
200
215
|
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
216
|
+
const presenceLegendTooltip = (): { title: string; body: React.ReactNode } => {
|
|
217
|
+
const Row = ({ color, label }: { color: string; label: string }) => (
|
|
218
|
+
<div className="flex items-center gap-2">
|
|
219
|
+
<span className="inline-block h-2 w-2 rounded-full" style={{ background: color }} />
|
|
220
|
+
<span>{label}</span>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
return {
|
|
224
|
+
title: 'Presence types',
|
|
225
|
+
body: (
|
|
226
|
+
<div className="grid gap-1">
|
|
227
|
+
<Row color={presenceColor('certified')} label="Certified — Verified by credential issuers." />
|
|
228
|
+
<Row color={presenceColor('observed')} label="Observed — Evidence directly from code and repos." />
|
|
229
|
+
<Row color={presenceColor('self-reported')} label="Self-reported — Claims (bios, profiles, resumes)." />
|
|
230
|
+
</div>
|
|
231
|
+
)
|
|
232
|
+
};
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const experienceLegendTooltip = (): { title: string; body: React.ReactNode } => {
|
|
236
|
+
return {
|
|
237
|
+
title: 'Experience',
|
|
238
|
+
body: (
|
|
239
|
+
<div className="grid gap-1 mt-2">
|
|
240
|
+
<div>
|
|
241
|
+
Calculated as time between first and most recent observed evidence across repositories.
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
)
|
|
245
|
+
};
|
|
207
246
|
};
|
|
208
247
|
|
|
209
248
|
const leftColumnGrid = useMemo(() => (skillsGrid || []).slice(0, 5), [skillsGrid]);
|
|
@@ -211,7 +250,7 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
|
|
|
211
250
|
|
|
212
251
|
if (!hasRadar) return null;
|
|
213
252
|
|
|
214
|
-
const columnComponent = (entry: { label: string; years?: number; presence?: string; sources?: string[] } | '', idx: number, isLeft: boolean) => {
|
|
253
|
+
const columnComponent = (entry: { label: string; years?: number; presence?: string; presenceTypes?: Array<'certified' | 'observed' | 'self-reported'>; sources?: string[] } | '', idx: number, isLeft: boolean) => {
|
|
215
254
|
return (
|
|
216
255
|
<div key={idx} className="flex items-stretch justify-between gap-3 min-w-0">
|
|
217
256
|
<div className="flex flex-col min-w-0 justify-center">
|
|
@@ -219,59 +258,115 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
|
|
|
219
258
|
<span className={'inline-block h-2 w-2 rounded-full'} style={{ backgroundColor: entry ? 'var(--icon-button-secondary)' : 'transparent', flexShrink: 0 }} />
|
|
220
259
|
<span className="shrink-0 opacity-70 ">{idx + (isLeft ? 1 : 6)}.</span>
|
|
221
260
|
{entry && typeof entry !== 'string' ? (
|
|
222
|
-
<span className="truncate" title={entry.label}>
|
|
261
|
+
<span className="truncate" title={entry.label}>
|
|
262
|
+
{entry.label}
|
|
263
|
+
{/* Evidence count bullet */}
|
|
264
|
+
{(() => {
|
|
265
|
+
const meta = skillsMeta?.[entry.label];
|
|
266
|
+
const count = Number((meta as any)?.evidenceCount || 0);
|
|
267
|
+
return count > 0 ? (
|
|
268
|
+
<>
|
|
269
|
+
{' '}<span className="opacity-60">•</span>{' '}
|
|
270
|
+
<span
|
|
271
|
+
className="underline decoration-dotted underline-offset-2 cursor-help opacity-80"
|
|
272
|
+
onMouseEnter={(e) =>
|
|
273
|
+
showLegendTooltipAt(
|
|
274
|
+
e.currentTarget,
|
|
275
|
+
'Evidence count',
|
|
276
|
+
'Total number of sources across observed, certified, and self-reported evidence for this skill.'
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
onMouseLeave={hideLegendTooltip}
|
|
280
|
+
>
|
|
281
|
+
{count}
|
|
282
|
+
</span>
|
|
283
|
+
</>
|
|
284
|
+
) : null;
|
|
285
|
+
})()}
|
|
286
|
+
</span>
|
|
223
287
|
) : (
|
|
224
288
|
<span className="truncate">{typeof entry === 'string' ? entry : '\u00A0'}</span>
|
|
225
289
|
)}
|
|
226
290
|
</div>
|
|
227
291
|
<span className="text-xs text-[var(--text-secondary)] flex flex-wrap items-center gap-1">
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
292
|
+
{entry && typeof entry !== 'string' ? (
|
|
293
|
+
<>
|
|
294
|
+
<span
|
|
295
|
+
className="underline decoration-dotted underline-offset-2 cursor-help"
|
|
296
|
+
onMouseEnter={(e) => showLegendTooltipAt(e.currentTarget, 'Sources', 'The source where we observed this skill.')}
|
|
297
|
+
onMouseLeave={hideLegendTooltip}
|
|
298
|
+
>
|
|
299
|
+
Sources
|
|
300
|
+
</span>:
|
|
301
|
+
{Array.isArray((entry as any).sources) && (entry as any).sources.length > 0 ? (
|
|
302
|
+
(() => {
|
|
303
|
+
const sourceProviders: string[] = ((entry as any).sources as string[]).map((src: string) => {
|
|
304
|
+
const str = String(src);
|
|
305
|
+
let provider = str.split(':')[0] || '';
|
|
306
|
+
if (!provider || provider === str) {
|
|
307
|
+
// If split(':')[0] didn't find a delimiter or provider (i.e., no ':'), try split('.')
|
|
308
|
+
provider = str.split('.')[0] || '';
|
|
309
|
+
}
|
|
310
|
+
return provider.toLowerCase();
|
|
311
|
+
});
|
|
312
|
+
const uniqueProviders = Array.from(new Set<string>(sourceProviders));
|
|
313
|
+
const filteredProviders = uniqueProviders.filter((provider) =>
|
|
314
|
+
providers.includes(provider.toLowerCase())
|
|
315
|
+
);
|
|
316
|
+
return filteredProviders.map((provider) => (
|
|
317
|
+
<ProviderIcon key={provider} name={provider} />
|
|
318
|
+
));
|
|
319
|
+
})()
|
|
320
|
+
) : null}
|
|
321
|
+
</>
|
|
322
|
+
) : (
|
|
323
|
+
<span className="opacity-0 whitespace-nowrap">'\u00A0'</span>
|
|
324
|
+
)}
|
|
255
325
|
</span>
|
|
256
326
|
</div>
|
|
257
327
|
{entry && typeof entry !== 'string' ? (
|
|
258
|
-
<div
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
328
|
+
<div
|
|
329
|
+
className="flex flex-col items-end leading-tight h-full justify-start text-base"
|
|
330
|
+
>
|
|
331
|
+
<div className="pb-1">
|
|
332
|
+
{entry.years ? (
|
|
263
333
|
<span
|
|
264
|
-
className="whitespace-nowrap text-
|
|
334
|
+
className="whitespace-nowrap text-[var(--text-secondary)] underline decoration-dotted underline-offset-2 cursor-help"
|
|
265
335
|
onMouseEnter={(e) => {
|
|
266
|
-
const copy =
|
|
336
|
+
const copy = experienceLegendTooltip();
|
|
267
337
|
showLegendTooltipAt(e.currentTarget, copy.title, copy.body);
|
|
268
338
|
}}
|
|
269
339
|
onMouseLeave={hideLegendTooltip}
|
|
270
340
|
>
|
|
271
|
-
{entry.
|
|
341
|
+
{`${entry.years} Years`}
|
|
272
342
|
</span>
|
|
273
|
-
|
|
274
|
-
|
|
343
|
+
) : (
|
|
344
|
+
<span className="opacity-0 whitespace-nowrap text-[var(--text-secondary)]">0 Years</span>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
<div
|
|
348
|
+
onMouseEnter={(e) => {
|
|
349
|
+
const copy = presenceLegendTooltip();
|
|
350
|
+
showLegendTooltipAt(e.currentTarget, copy.title, copy.body);
|
|
351
|
+
}}
|
|
352
|
+
onMouseLeave={hideLegendTooltip}
|
|
353
|
+
className="pt-1"
|
|
354
|
+
>
|
|
355
|
+
{(() => {
|
|
356
|
+
const types = Array.isArray(entry.presenceTypes) ? entry.presenceTypes : (entry.presence ? [String(entry.presence) as any] : []);
|
|
357
|
+
const hasAny = types.length > 0;
|
|
358
|
+
return hasAny ? (
|
|
359
|
+
<div
|
|
360
|
+
className="flex items-center gap-1"
|
|
361
|
+
|
|
362
|
+
>
|
|
363
|
+
{types.map((t) => (
|
|
364
|
+
<span key={t} className="inline-block h-2 w-2 rounded-full" style={{ background: presenceColor(t) }} />
|
|
365
|
+
))}
|
|
366
|
+
</div>
|
|
367
|
+
) : <span className="opacity-0 whitespace-nowrap">.</span>;
|
|
368
|
+
})()}
|
|
369
|
+
</div>
|
|
275
370
|
</div>
|
|
276
371
|
) : null}
|
|
277
372
|
</div>
|
|
@@ -297,7 +392,7 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
|
|
|
297
392
|
>
|
|
298
393
|
<div className="flex items-center gap-2">
|
|
299
394
|
<span className="inline-block h-3 w-3 rounded-full" style={{ background: green1 }} />
|
|
300
|
-
<span>Size =
|
|
395
|
+
<span>Size = evidence count per category</span>
|
|
301
396
|
</div>
|
|
302
397
|
<div className="flex items-center gap-2 mt-1">
|
|
303
398
|
<span className="inline-block h-3 w-3 rounded-full" style={{ background: green5 }} />
|
|
@@ -242,10 +242,12 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
242
242
|
</div>
|
|
243
243
|
<h3 className="sm:text-lg text-base font-semibold" style={{ color: 'var(--text-main)'}}>How your data is handled</h3>
|
|
244
244
|
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md mt-2 leading-relaxed">
|
|
245
|
-
|
|
245
|
+
We understand that giving access to your private repositories can be a bit scary. So here's the deal: We install the KYD GitHub App in your account. The app has
|
|
246
|
+
<span className="mx-1"><Link href="https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps?apiVersion=2022-11-28#repository-permissions-for-contents" target="_blank" rel="noopener noreferrer" className="underline" style={{ color: 'var(--icon-accent)'}}>Contents <ExternalLink className="size-3 inline-block" /></Link></span>
|
|
247
|
+
read access - only to the repositories you select. Then, once you request a badge assessment, we read the repositories and analyze the code, then its deleted, forever. Your code is not accessible to anyone, not even us.
|
|
246
248
|
</p>
|
|
247
249
|
<p className="sm:text-sm text-xs text-[var(--text-secondary)] mx-auto max-w-md mt-3 leading-relaxed">
|
|
248
|
-
For details, see our{' '}
|
|
250
|
+
For other details, see our{' '}
|
|
249
251
|
<Link href="https://www.knowyourdeveloper.ai/privacy-policy" target="_blank" rel="noopener noreferrer" className="underline" style={{ color: 'var(--icon-accent)'}}>
|
|
250
252
|
Privacy Policy <ExternalLink className="size-3 inline-block ml-1" />
|
|
251
253
|
</Link>.
|
|
@@ -368,7 +370,7 @@ export function ConnectAccounts(props: ConnectAccountsProps) {
|
|
|
368
370
|
onClick={() => setShowDataHandling(true)}
|
|
369
371
|
className="sm:text-sm text-xs underline text-[var(--icon-accent)] hover:text-[var(--icon-accent-hover)]"
|
|
370
372
|
>
|
|
371
|
-
|
|
373
|
+
How KYD Handles Your Data
|
|
372
374
|
</button>
|
|
373
375
|
</p>
|
|
374
376
|
<div className="flex flex-col sm:flex-row w-full gap-3 mt-2 justify-center items-center">
|
package/src/types.ts
CHANGED
|
@@ -397,11 +397,21 @@ export interface GraphInsightsPayload {
|
|
|
397
397
|
certified?: number; // 0-100
|
|
398
398
|
// New: experience metric (0-100) for color saturation
|
|
399
399
|
experience?: number;
|
|
400
|
+
// New: total evidence count across all skills in this category
|
|
401
|
+
evidence_count_total?: number;
|
|
400
402
|
}>;
|
|
401
403
|
// New: mapping of category -> list of skills contributing to that category
|
|
402
404
|
skillsByCategory?: Record<string, string[]>;
|
|
403
405
|
// New: per-skill metadata used by UI (e.g., presence label, experience years)
|
|
404
|
-
skillsMeta?: Record<string, {
|
|
406
|
+
skillsMeta?: Record<string, {
|
|
407
|
+
presence?: 'certified' | 'observed' | 'self-reported';
|
|
408
|
+
// New: list of presence types for multi-dot rendering
|
|
409
|
+
presenceTypes?: Array<'certified' | 'observed' | 'self-reported'>;
|
|
410
|
+
years?: number;
|
|
411
|
+
sources?: string[];
|
|
412
|
+
// New: total number of evidence sources across observed/self-reported/certified
|
|
413
|
+
evidenceCount?: number;
|
|
414
|
+
}>;
|
|
405
415
|
// New: Flattened list of business rule selections (for appendix)
|
|
406
416
|
business_rules_all?: Array<{
|
|
407
417
|
provider: string;
|