kyd-shared-badge 0.3.100 → 0.3.102

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kyd-shared-badge",
3
- "version": "0.3.100",
3
+ "version": "0.3.102",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -21,7 +21,7 @@
21
21
  "@aws-sdk/lib-dynamodb": "^3.893.0",
22
22
  "@chatscope/chat-ui-kit-react": "^2.1.1",
23
23
  "@chatscope/chat-ui-kit-styles": "^1.4.0",
24
- "@knowyourdeveloper/react-bubble-chart": "^1.0.6",
24
+ "@knowyourdeveloper/react-bubble-chart": "^1.0.7",
25
25
  "@knowyourdeveloper/react-gauge-component": "^1.1.30",
26
26
  "@radix-ui/react-slot": "^1.2.3",
27
27
  "@radix-ui/react-tooltip": "^1.2.8",
@@ -224,6 +224,7 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
224
224
  countries={(assessmentResult?.screening_sources?.ip_risk_analysis?.raw_data?.countries) || []}
225
225
  accountAuthenticity={assessmentResult?.account_authenticity}
226
226
  companyName={badgeData.companyName}
227
+ sourcesProviders={(badgeData?.connectedAccounts || []).map(a => (a?.name || '').toLowerCase())}
227
228
  />
228
229
  </div>
229
230
  {/* Top-right: Role match section */}
@@ -97,8 +97,11 @@ export default function GaugeCard({
97
97
  labels={{
98
98
  valueLabel: {
99
99
  // Hide center text; show tier labels around the arc instead
100
- formatTextValue: () => '',
100
+ formatTextValue: () => displayLabel,
101
101
  matchColorWithArc: true,
102
+ style: {
103
+ textShadow: 'none'
104
+ }
102
105
  },
103
106
  tickLabels: tickLabels,
104
107
  }}
@@ -5,6 +5,7 @@ import { formatLocalDate } from '../utils/date';
5
5
  import countriesLib from 'i18n-iso-countries';
6
6
  import enLocale from 'i18n-iso-countries/langs/en.json';
7
7
  import { FiInfo, FiAlertTriangle } from 'react-icons/fi';
8
+ import { ProviderIcon } from '../utils/provider';
8
9
 
9
10
  // Register English locale once at module import time
10
11
  countriesLib.registerLocale(enLocale);
@@ -41,13 +42,28 @@ interface ReportHeaderProps {
41
42
  countries?: string[];
42
43
  accountAuthenticity?: { label?: string; description?: string };
43
44
  companyName?: string;
45
+ sourcesProviders?: string[];
44
46
  }
45
47
 
46
- const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImageUrl, summary, enterpriseMatch, countries = [], accountAuthenticity, companyName }: ReportHeaderProps) => {
48
+ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImageUrl, summary, enterpriseMatch, countries = [], accountAuthenticity, companyName, sourcesProviders = [] }: ReportHeaderProps) => {
47
49
  // Use the dynamic image if available, otherwise fall back to the score-based one.
48
50
  const finalBadgeImageUrl = badgeImageUrl || getBadgeImageUrl(score || 0);
49
51
  const tint = hexToRgba(pickTint(score || 0), 0.06);
50
52
  const matchLabel = enterpriseMatch?.label;
53
+ const sources = (() => {
54
+ const primary = Array.isArray(sourcesProviders) ? sourcesProviders : [];
55
+ // Fallback to authenticity oauth list if no explicit sources provided
56
+ const fallback = (accountAuthenticity as any)?.oauth_connected as string[] | undefined;
57
+ const combined = (primary && primary.length ? primary : (fallback || [])).filter(Boolean);
58
+ // Deduplicate while preserving order
59
+ const seen: Record<string, boolean> = {};
60
+ return combined.filter((n) => {
61
+ const key = (n || '').toLowerCase();
62
+ if (seen[key]) return false;
63
+ seen[key] = true;
64
+ return true;
65
+ });
66
+ })();
51
67
 
52
68
  return (
53
69
  <div
@@ -111,6 +127,20 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
111
127
  );
112
128
  })()
113
129
  )}
130
+ {sources.length > 0 && (
131
+ <p className={'flex items-center gap-2 mt-2'}>
132
+ <a href="#appendix-connected" className={'inline-flex items-center gap-2 group'} style={{ color: 'var(--text-secondary)' }}>
133
+ <span className={'font-semibold'}>Sources:</span>
134
+ <span className={'flex items-center gap-2'}>
135
+ {sources.map((provider, idx) => (
136
+ <span key={`${provider}-${idx}`} className={'text-sm'} style={{ color: 'var(--text-main)' }}>
137
+ <ProviderIcon name={provider} className={'inline-block align-middle'} />
138
+ </span>
139
+ ))}
140
+ </span>
141
+ </a>
142
+ </p>
143
+ )}
114
144
  </div>
115
145
  {(enterpriseMatch?.description || matchLabel) ? (
116
146
  <div className={'hidden md:block text-sm space-y-2 pt-4'} style={{ borderTop: '1px solid var(--icon-button-secondary)' }}>
@@ -45,7 +45,7 @@ export default function RoleOverviewCard({
45
45
  }
46
46
  },
47
47
  ticks: [
48
- { value: 0, valueConfig: { formatTextValue: () => 'Incompatible' } },
48
+ { value: 0, valueConfig: { formatTextValue: () => 'Very Weak' } }, // Incompatible is too long, gets cut off
49
49
  { value: 25, valueConfig: { formatTextValue: () => 'Weak' } },
50
50
  { value: 50, valueConfig: { formatTextValue: () => 'Partial' } },
51
51
  { value: 75, valueConfig: { formatTextValue: () => 'Strong' } },
@@ -55,14 +55,14 @@ export default function RoleOverviewCard({
55
55
 
56
56
  return (
57
57
  <div
58
- className={'rounded-md p-5 border flex flex-col h-full'}
58
+ className={'rounded-md border flex flex-col h-full'}
59
59
  style={{
60
60
  backgroundColor: 'var(--content-card-background)',
61
61
  borderColor: 'var(--icon-button-secondary)',
62
62
  backgroundImage: `linear-gradient(${headerTint}, ${headerTint})`,
63
63
  }}
64
64
  >
65
- <div className="mb-3 flex items-start justify-between gap-2">
65
+ <div className="mb-3 px-5 pt-5 flex items-start justify-between gap-2">
66
66
  <div>
67
67
  <div className={'font-semibold'} style={{ color: 'var(--text-main)' }}>{title}</div>
68
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>
@@ -77,11 +77,12 @@ export default function RoleOverviewCard({
77
77
  </div>
78
78
  </span>
79
79
  </div>
80
- <div className="flex-grow flex flex-col items-center justify-center" style={{ minHeight: 200 }}>
81
- <div className="relative" style={{ width: '100%', aspectRatio: '2 / 1', maxWidth: 360 }}>
80
+ <div className="flex-grow flex flex-col items-center justify-center pb-5" style={{ minHeight: 200 }}>
81
+ <div className="relative group" style={{ width: '100%', aspectRatio: '2 / 1', maxWidth: 390 }}>
82
82
  <GaugeComponent
83
83
  type="semicircle"
84
- 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 }}
85
86
  value={pct}
86
87
  minValue={0}
87
88
  maxValue={100}
@@ -89,6 +90,9 @@ export default function RoleOverviewCard({
89
90
  valueLabel: {
90
91
  formatTextValue: () => displayLabel,
91
92
  matchColorWithArc: true,
93
+ style: {
94
+ textShadow: 'none'
95
+ }
92
96
  },
93
97
  tickLabels: tickLabels,
94
98
  }}
@@ -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 = {
@@ -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'; presenceTypes?: Array<'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);
@@ -85,7 +87,7 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
85
87
  };
86
88
  const hideLegendTooltip = () => setLegendTooltip(null);
87
89
 
88
- // ratio drives size: average of observed/self_reported/certified
90
+ // ratio drives size by default; prefer backend evidence_count_total when present
89
91
  const bubbles = useMemo(() => {
90
92
  const seriesAvg = (d: SkillsRadarPoint): number => {
91
93
  const vals = [Number(d.observed || 0), Number(d.self_reported || 0), Number(d.certified || 0)];
@@ -94,10 +96,19 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
94
96
  return Math.max(0, Math.min(100, Math.round(base.reduce((a, b) => a + b, 0) / (base.length || 1))));
95
97
  };
96
98
 
97
- const maxValue = Math.max(1, ...skillsRadarLimited.map(seriesAvg));
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));
98
106
 
99
107
  return skillsRadarLimited.map((d) => {
100
- const value = seriesAvg(d);
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));
101
112
  const experience = Math.max(0, Math.min(100, Number(d.experience || 0)));
102
113
  const size = Math.max(2, Math.round((value / maxValue) * 100)); // 2..100
103
114
  const color = 'var(--content-card-background)';
@@ -105,11 +116,13 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
105
116
  label: d.axis,
106
117
  value: size,
107
118
  color,
108
- tooltip: `${d.axis}\nRatio: ${value}\nExperience: ${experience}`,
109
- data: { ratio: value, experience }
119
+ tooltip: evidenceAvailable
120
+ ? `${d.axis}\nEvidence: ${value} sources\nExperience: ${experience}`
121
+ : `${d.axis}\nRatio: ${ratio}\nExperience: ${experience}`,
122
+ data: { ratio, experience, evidence: value }
110
123
  };
111
124
  });
112
- }, [skillsRadarLimited]);
125
+ }, [skillsRadarLimited, skillsCategoryRadar]);
113
126
 
114
127
  const bubbleData = useMemo(() => {
115
128
  return bubbles.map((b) => ({
@@ -245,39 +258,70 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
245
258
  <span className={'inline-block h-2 w-2 rounded-full'} style={{ backgroundColor: entry ? 'var(--icon-button-secondary)' : 'transparent', flexShrink: 0 }} />
246
259
  <span className="shrink-0 opacity-70 ">{idx + (isLeft ? 1 : 6)}.</span>
247
260
  {entry && typeof entry !== 'string' ? (
248
- <span className="truncate" title={entry.label}>{entry.label}</span>
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>
249
287
  ) : (
250
288
  <span className="truncate">{typeof entry === 'string' ? entry : '\u00A0'}</span>
251
289
  )}
252
290
  </div>
253
291
  <span className="text-xs text-[var(--text-secondary)] flex flex-wrap items-center gap-1">
254
- <span
255
- className="underline decoration-dotted underline-offset-2 cursor-help"
256
- onMouseEnter={(e) => showLegendTooltipAt(e.currentTarget, 'Sources', 'The source where we observed this skill.')}
257
- onMouseLeave={hideLegendTooltip}
258
- >
259
- Sources
260
- </span>:
261
- {Array.isArray((entry as any).sources) && (entry as any).sources.length > 0 ? (
262
- (() => {
263
- const sourceProviders: string[] = ((entry as any).sources as string[]).map((src: string) => {
264
- const str = String(src);
265
- let provider = str.split(':')[0] || '';
266
- if (!provider || provider === str) {
267
- // If split(':')[0] didn't find a delimiter or provider (i.e., no ':'), try split('.')
268
- provider = str.split('.')[0] || '';
269
- }
270
- return provider.toLowerCase();
271
- });
272
- const uniqueProviders = Array.from(new Set<string>(sourceProviders));
273
- const filteredProviders = uniqueProviders.filter((provider) =>
274
- providers.includes(provider.toLowerCase())
275
- );
276
- return filteredProviders.map((provider) => (
277
- <ProviderIcon key={provider} name={provider} />
278
- ));
279
- })()
280
- ) : null}
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
+ )}
281
325
  </span>
282
326
  </div>
283
327
  {entry && typeof entry !== 'string' ? (
@@ -348,7 +392,7 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
348
392
  >
349
393
  <div className="flex items-center gap-2">
350
394
  <span className="inline-block h-3 w-3 rounded-full" style={{ background: green1 }} />
351
- <span>Size = ratio of observed/self-reported/certified</span>
395
+ <span>Size = evidence count per category</span>
352
396
  </div>
353
397
  <div className="flex items-center gap-2 mt-1">
354
398
  <span className="inline-block h-3 w-3 rounded-full" style={{ background: green5 }} />
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, { presence?: 'certified' | 'observed' | 'self-reported'; years?: number; sources?: 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;