kyd-shared-badge 0.3.94 → 0.3.96
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 +1 -1
- package/src/components/SkillsBubble.tsx +177 -64
- package/src/types.ts +19 -0
package/package.json
CHANGED
|
@@ -288,7 +288,7 @@ const SharedBadgeDisplay = ({ badgeData, chatProps, headless }: { badgeData: Pub
|
|
|
288
288
|
<Reveal headless={isHeadless}>
|
|
289
289
|
<div className={'kyd-avoid-break'}>
|
|
290
290
|
<h4 className={'text-2xl font-semibold mb-3'} style={{ color: 'var(--text-main)' }}>Skills Footprint</h4>
|
|
291
|
-
<SkillsBubble skillsCategoryRadar={graphInsights?.skillsCategoryRadar} headless={isHeadless} />
|
|
291
|
+
<SkillsBubble skillsCategoryRadar={graphInsights?.skillsCategoryRadar} headless={isHeadless} skillsByCategory={assessmentResult?.graph_insights?.skillsByCategory} skillsMeta={assessmentResult?.graph_insights?.skillsMeta} />
|
|
292
292
|
</div>
|
|
293
293
|
</Reveal>
|
|
294
294
|
<div className={'pt-6 text-sm text-center'} style={{ color: 'var(--text-secondary)' }}>
|
|
@@ -4,6 +4,8 @@ import React, { useMemo, useRef, useState, useEffect } from 'react';
|
|
|
4
4
|
import { BubbleChart } from '@knowyourdeveloper/react-bubble-chart';
|
|
5
5
|
import '@knowyourdeveloper/react-bubble-chart/style.css';
|
|
6
6
|
import { green1, green2, green3, green4, green5 } from '../colors';
|
|
7
|
+
import { ProviderIcon } from '../utils/provider';
|
|
8
|
+
import { providers } from '../types';
|
|
7
9
|
|
|
8
10
|
type SkillsRadarPoint = {
|
|
9
11
|
axis: string;
|
|
@@ -47,23 +49,14 @@ const TooltipBox = ({ state }: { state: HoverTooltipState }) => {
|
|
|
47
49
|
);
|
|
48
50
|
};
|
|
49
51
|
|
|
50
|
-
const pickGreenByExperience = (experience: number): string => {
|
|
51
|
-
const exp = Math.max(0, Math.min(100, Number(experience || 0)));
|
|
52
|
-
if (exp >= 80) return green1;
|
|
53
|
-
if (exp >= 60) return green2;
|
|
54
|
-
if (exp >= 40) return green3;
|
|
55
|
-
if (exp >= 20) return green4;
|
|
56
|
-
return green5;
|
|
57
|
-
};
|
|
58
52
|
|
|
59
|
-
export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, headless }: { skillsCategoryRadar?: SkillsRadarPoint[]; skillsByCategory?: Record<string, string[]>; headless?: boolean }) {
|
|
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 }) {
|
|
60
54
|
const hasRadar = !!(skillsCategoryRadar && skillsCategoryRadar.length > 0);
|
|
61
55
|
const skillsRadarLimited = (skillsCategoryRadar || []).slice(0, 24);
|
|
62
56
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
63
57
|
const legendRef = useRef<HTMLDivElement>(null);
|
|
64
58
|
const [legendTooltip, setLegendTooltip] = useState<HoverTooltipState>(null);
|
|
65
59
|
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
|
66
|
-
|
|
67
60
|
useEffect(() => {
|
|
68
61
|
if (typeof window !== 'undefined') {
|
|
69
62
|
const id = window.setTimeout(() => {
|
|
@@ -73,6 +66,23 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, he
|
|
|
73
66
|
}
|
|
74
67
|
}, []);
|
|
75
68
|
|
|
69
|
+
// Tooltip helpers for legend/columns area
|
|
70
|
+
const computeTooltipPosition = (target: HTMLElement) => {
|
|
71
|
+
const host = legendRef.current;
|
|
72
|
+
if (!host) return { x: 0, y: 0 };
|
|
73
|
+
const rect = target.getBoundingClientRect();
|
|
74
|
+
const hostRect = host.getBoundingClientRect();
|
|
75
|
+
const x = rect.left - hostRect.left;
|
|
76
|
+
const y = rect.bottom - hostRect.top + 6;
|
|
77
|
+
return { x, y };
|
|
78
|
+
};
|
|
79
|
+
const showLegendTooltipAt = (target: EventTarget | null, title: string, body?: string) => {
|
|
80
|
+
if (!(target instanceof HTMLElement)) return;
|
|
81
|
+
const { x, y } = computeTooltipPosition(target);
|
|
82
|
+
setLegendTooltip({ visible: true, x, y, title, body });
|
|
83
|
+
};
|
|
84
|
+
const hideLegendTooltip = () => setLegendTooltip(null);
|
|
85
|
+
|
|
76
86
|
// ratio drives size: average of observed/self_reported/certified
|
|
77
87
|
const bubbles = useMemo(() => {
|
|
78
88
|
const seriesAvg = (d: SkillsRadarPoint): number => {
|
|
@@ -128,23 +138,144 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, he
|
|
|
128
138
|
const skillsGrid = useMemo(() => {
|
|
129
139
|
const cat = activeCategory || '';
|
|
130
140
|
const list = (skillsByCategory && cat in (skillsByCategory || {})) ? (skillsByCategory?.[cat] || []) : [];
|
|
131
|
-
|
|
141
|
+
// Enrich with meta and sort by years desc
|
|
142
|
+
const enriched = list.map((name) => {
|
|
143
|
+
const meta = skillsMeta?.[name] || {};
|
|
144
|
+
const sources = Array.isArray((meta as any).sources) ? (meta as any).sources as string[] : [];
|
|
145
|
+
return { name, years: Number(meta.years || 0), presence: (meta.presence as string) || '', sources };
|
|
146
|
+
}).sort((a, b) => b.years - a.years || a.name.localeCompare(b.name));
|
|
147
|
+
const items = enriched.slice(0, 10);
|
|
132
148
|
const overflow = list.length - items.length;
|
|
133
|
-
const display: string[] = [];
|
|
134
|
-
for (let i = 0; i < Math.min(9, items.length); i++)
|
|
149
|
+
const display: Array<{ label: string; years?: number; presence?: string; sources?: string[] } | ''> = [];
|
|
150
|
+
for (let i = 0; i < Math.min(9, items.length); i++) {
|
|
151
|
+
const it = items[i];
|
|
152
|
+
const label = it.name ? `${it.name}` : '';
|
|
153
|
+
display.push({ label, years: it.years, presence: it.presence, sources: it.sources });
|
|
154
|
+
}
|
|
135
155
|
if (list.length > 10) {
|
|
136
|
-
|
|
156
|
+
// Aggregate years and sources for remaining datapoints beyond the top 10
|
|
157
|
+
const remaining = enriched.slice(10);
|
|
158
|
+
const aggregatedYears = remaining.reduce((sum, it) => sum + Number(it.years || 0), 0);
|
|
159
|
+
const aggregatedSourcesSet = new Set<string>();
|
|
160
|
+
for (const it of remaining) {
|
|
161
|
+
if (Array.isArray(it.sources)) {
|
|
162
|
+
for (const src of it.sources) aggregatedSourcesSet.add(String(src));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const aggregatedSources = Array.from(aggregatedSourcesSet);
|
|
166
|
+
display.push({ label: `Others (${overflow})`, years: aggregatedYears, sources: aggregatedSources });
|
|
137
167
|
} else if (items.length >= 10) {
|
|
138
|
-
|
|
168
|
+
const it = items[9];
|
|
169
|
+
display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, sources: it?.sources });
|
|
139
170
|
} else {
|
|
140
|
-
if (items.length > 9)
|
|
171
|
+
if (items.length > 9) {
|
|
172
|
+
const it = items[9];
|
|
173
|
+
display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, sources: it?.sources });
|
|
174
|
+
}
|
|
141
175
|
}
|
|
142
176
|
while (display.length < 10) display.push('');
|
|
143
177
|
return display;
|
|
144
|
-
}, [activeCategory, skillsByCategory]);
|
|
178
|
+
}, [activeCategory, skillsByCategory, skillsMeta]);
|
|
179
|
+
|
|
180
|
+
// Compute category percent share (by ratio) for header display
|
|
181
|
+
const categoryPercentMap = useMemo(() => {
|
|
182
|
+
const total = bubbles.reduce((sum, b) => sum + (b.data?.ratio || 0), 0);
|
|
183
|
+
const map: Record<string, number> = {};
|
|
184
|
+
for (const b of bubbles) {
|
|
185
|
+
const p = total > 0 ? Math.round(((b.data?.ratio || 0) / total) * 100) : 0;
|
|
186
|
+
map[b.label] = p;
|
|
187
|
+
}
|
|
188
|
+
return map;
|
|
189
|
+
}, [bubbles]);
|
|
190
|
+
|
|
191
|
+
const presenceColor = (presence?: string) => {
|
|
192
|
+
const p = String(presence || '').toLowerCase();
|
|
193
|
+
if (p === 'self-reported') return green5;
|
|
194
|
+
if (p === 'observed') return green3;
|
|
195
|
+
if (p === 'certified') return green1;
|
|
196
|
+
return 'var(--icon-button-secondary)';
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const presenceTooltipCopy = (presence?: string): { title: string; body: string } => {
|
|
200
|
+
const p = String(presence || '').toLowerCase();
|
|
201
|
+
if (p === 'self-reported') return { title: 'Self-reported', body: 'Claims (bios, profiles, resumes).' };
|
|
202
|
+
if (p === 'observed') return { title: 'Observed', body: 'Evidence directly from code and repos.' };
|
|
203
|
+
if (p === 'certified') return { title: 'Certified', body: 'Verified by credential issuers.' };
|
|
204
|
+
return { title: 'Info', body: '' };
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const leftColumnGrid = useMemo(() => (skillsGrid || []).slice(0, 5), [skillsGrid]);
|
|
208
|
+
const rightColumnGrid = useMemo(() => (skillsGrid || []).slice(5, 10), [skillsGrid]);
|
|
145
209
|
|
|
146
210
|
if (!hasRadar) return null;
|
|
147
211
|
|
|
212
|
+
const columnComponent = (entry: { label: string; years?: number; presence?: string; sources?: string[] } | '', idx: number, isLeft: boolean) => {
|
|
213
|
+
return (
|
|
214
|
+
<div key={idx} className="flex items-stretch justify-between gap-3 min-w-0">
|
|
215
|
+
<div className="flex flex-col min-w-0 justify-center">
|
|
216
|
+
<div className="flex items-center gap-2 min-w-0 text-lg text-[var(--text-main)]">
|
|
217
|
+
<span className={'inline-block h-2 w-2 rounded-full'} style={{ backgroundColor: entry ? 'var(--icon-button-secondary)' : 'transparent', flexShrink: 0 }} />
|
|
218
|
+
<span className="shrink-0 opacity-70">{idx + (isLeft ? 1 : 6)}.</span>
|
|
219
|
+
{entry && typeof entry !== 'string' ? (
|
|
220
|
+
<span className="truncate" title={entry.label}>{entry.label}</span>
|
|
221
|
+
) : (
|
|
222
|
+
<span className="truncate">{typeof entry === 'string' ? entry : '\u00A0'}</span>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
<span className="text-xs text-[var(--text-secondary)] flex flex-wrap items-center gap-1">
|
|
226
|
+
<span
|
|
227
|
+
className="underline decoration-dotted underline-offset-2 cursor-help"
|
|
228
|
+
onMouseEnter={(e) => showLegendTooltipAt(e.currentTarget, 'Sources', 'The source where we observed this skill.')}
|
|
229
|
+
onMouseLeave={hideLegendTooltip}
|
|
230
|
+
>
|
|
231
|
+
Sources
|
|
232
|
+
</span>:
|
|
233
|
+
{Array.isArray((entry as any).sources) && (entry as any).sources.length > 0 ? (
|
|
234
|
+
(() => {
|
|
235
|
+
const sourceProviders: string[] = ((entry as any).sources as string[]).map((src: string) => {
|
|
236
|
+
const str = String(src);
|
|
237
|
+
let provider = str.split(':')[0] || '';
|
|
238
|
+
if (!provider || provider === str) {
|
|
239
|
+
// If split(':')[0] didn't find a delimiter or provider (i.e., no ':'), try split('.')
|
|
240
|
+
provider = str.split('.')[0] || '';
|
|
241
|
+
}
|
|
242
|
+
return provider.toLowerCase();
|
|
243
|
+
});
|
|
244
|
+
const uniqueProviders = Array.from(new Set<string>(sourceProviders));
|
|
245
|
+
const filteredProviders = uniqueProviders.filter((provider) =>
|
|
246
|
+
providers.includes(provider.toLowerCase())
|
|
247
|
+
);
|
|
248
|
+
return filteredProviders.map((provider) => (
|
|
249
|
+
<ProviderIcon key={provider} name={provider} />
|
|
250
|
+
));
|
|
251
|
+
})()
|
|
252
|
+
) : null}
|
|
253
|
+
</span>
|
|
254
|
+
</div>
|
|
255
|
+
{entry && typeof entry !== 'string' ? (
|
|
256
|
+
<div className="flex flex-col items-end leading-tight h-full justify-between text-base">
|
|
257
|
+
{entry.years ? <span className="whitespace-nowrap text-[var(--text-main)]">{`${entry.years} Years`}</span> : <span className="opacity-0 whitespace-nowrap text-[var(--text-main)]">0 Years</span>}
|
|
258
|
+
{entry.presence ? (
|
|
259
|
+
<div className="flex items-center gap-1">
|
|
260
|
+
<span className="inline-block h-2 w-2 rounded-full text-sm" style={{ background: presenceColor(entry.presence) }} />
|
|
261
|
+
<span
|
|
262
|
+
className="whitespace-nowrap text-sm underline decoration-dotted underline-offset-2 cursor-help"
|
|
263
|
+
onMouseEnter={(e) => {
|
|
264
|
+
const copy = presenceTooltipCopy(entry.presence);
|
|
265
|
+
showLegendTooltipAt(e.currentTarget, copy.title, copy.body);
|
|
266
|
+
}}
|
|
267
|
+
onMouseLeave={hideLegendTooltip}
|
|
268
|
+
>
|
|
269
|
+
{entry.presence}
|
|
270
|
+
</span>
|
|
271
|
+
</div>
|
|
272
|
+
) : <span className="opacity-0 whitespace-nowrap">.</span>}
|
|
273
|
+
</div>
|
|
274
|
+
) : null}
|
|
275
|
+
</div>
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
148
279
|
return (
|
|
149
280
|
<div className={'kyd-avoid-break'}>
|
|
150
281
|
<div ref={containerRef} style={{ width: '100%', height: 340, position: 'relative' }}>
|
|
@@ -199,64 +330,46 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, he
|
|
|
199
330
|
(node as HTMLElement).style.color = 'var(--text-main)';
|
|
200
331
|
(node as HTMLElement).style.pointerEvents = 'none';
|
|
201
332
|
|
|
202
|
-
const header = document.createElement('div');
|
|
203
|
-
header.className = 'mb-1';
|
|
204
333
|
const title = document.createElement('div');
|
|
205
334
|
title.className = 'font-medium';
|
|
206
335
|
title.style.color = 'var(--text-main)';
|
|
207
336
|
title.textContent = String(d.displayText || d._id || '');
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const content = document.createElement('div');
|
|
211
|
-
content.className = 'space-y-1';
|
|
212
|
-
const ratio = Number(d.value || 0);
|
|
213
|
-
const experience = Number(d.colorValue || 0);
|
|
214
|
-
|
|
215
|
-
const row1 = document.createElement('div');
|
|
216
|
-
row1.className = 'flex items-center justify-between gap-3';
|
|
217
|
-
const row1Left = document.createElement('span');
|
|
218
|
-
row1Left.style.color = 'var(--text-secondary)';
|
|
219
|
-
row1Left.textContent = 'Ratio';
|
|
220
|
-
const row1Right = document.createElement('span');
|
|
221
|
-
row1Right.className = 'font-medium';
|
|
222
|
-
row1Right.style.color = 'var(--text-main)';
|
|
223
|
-
row1Right.textContent = `${ratio}%`;
|
|
224
|
-
row1.appendChild(row1Left);
|
|
225
|
-
row1.appendChild(row1Right);
|
|
226
|
-
|
|
227
|
-
const row2 = document.createElement('div');
|
|
228
|
-
row2.className = 'flex items-center justify-between gap-3';
|
|
229
|
-
const row2Left = document.createElement('span');
|
|
230
|
-
row2Left.style.color = 'var(--text-secondary)';
|
|
231
|
-
row2Left.textContent = 'Experience';
|
|
232
|
-
const row2Right = document.createElement('span');
|
|
233
|
-
row2Right.className = 'font-medium';
|
|
234
|
-
row2Right.style.color = 'var(--text-main)';
|
|
235
|
-
row2Right.textContent = String(experience);
|
|
236
|
-
row2.appendChild(row2Left);
|
|
237
|
-
row2.appendChild(row2Right);
|
|
238
|
-
|
|
239
|
-
content.appendChild(row1);
|
|
240
|
-
content.appendChild(row2);
|
|
241
|
-
|
|
242
|
-
node.appendChild(header);
|
|
243
|
-
node.appendChild(content);
|
|
337
|
+
node.appendChild(title);
|
|
244
338
|
} catch {}
|
|
245
339
|
}}
|
|
246
340
|
/>
|
|
247
341
|
</div>
|
|
248
|
-
<div className=
|
|
342
|
+
<div className='mt-8'>
|
|
249
343
|
<div ref={legendRef} className={'kyd-avoid-break'} style={{ position: 'relative', breakInside: 'avoid', pageBreakInside: 'avoid' as unknown as undefined }}>
|
|
250
344
|
<div className="mb-2 text-xs font-medium" style={{ color: 'var(--text-main)' }}>
|
|
251
|
-
|
|
345
|
+
|
|
346
|
+
{activeCategory ? (
|
|
347
|
+
<span
|
|
348
|
+
className="ml-1 underline decoration-dotted underline-offset-2 cursor-help"
|
|
349
|
+
onMouseEnter={(e) =>
|
|
350
|
+
showLegendTooltipAt(
|
|
351
|
+
e.currentTarget,
|
|
352
|
+
'Category share',
|
|
353
|
+
'Percent = how focused you are in this category vs others (combined signals).'
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
onMouseLeave={hideLegendTooltip}
|
|
357
|
+
>
|
|
358
|
+
<span>{activeCategory ? `Category: ${activeCategory}` : 'Category'}</span> {`• ${categoryPercentMap[activeCategory] ?? 0}%`}
|
|
359
|
+
</span>
|
|
360
|
+
) : null}
|
|
252
361
|
</div>
|
|
253
|
-
<div className="grid grid-cols-2 gap-x-4
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
362
|
+
<div className="grid grid-cols-2 gap-x-4 text-xs" style={{ color: 'var(--text-secondary)', minHeight: 250 }}>
|
|
363
|
+
<div className="grid gap-2">
|
|
364
|
+
{leftColumnGrid.map((entry, idx) => (
|
|
365
|
+
columnComponent(entry, idx, true)
|
|
366
|
+
))}
|
|
367
|
+
</div>
|
|
368
|
+
<div className="grid gap-2">
|
|
369
|
+
{rightColumnGrid.map((entry, idx) => (
|
|
370
|
+
columnComponent(entry, idx, false)
|
|
371
|
+
))}
|
|
372
|
+
</div>
|
|
260
373
|
</div>
|
|
261
374
|
{!headless && <TooltipBox state={legendTooltip} />}
|
|
262
375
|
</div>
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
export const providers = [
|
|
2
|
+
'github',
|
|
3
|
+
'gitlab',
|
|
4
|
+
'credly',
|
|
5
|
+
'fiverr',
|
|
6
|
+
'kaggle',
|
|
7
|
+
'google_scholar',
|
|
8
|
+
'stackoverflow',
|
|
9
|
+
'linkedin',
|
|
10
|
+
'toptal',
|
|
11
|
+
'coursera',
|
|
12
|
+
'udemy',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
export type ProviderNames = typeof providers;
|
|
16
|
+
|
|
17
|
+
|
|
1
18
|
export type Provider = {
|
|
2
19
|
id: string;
|
|
3
20
|
name: string;
|
|
@@ -383,6 +400,8 @@ export interface GraphInsightsPayload {
|
|
|
383
400
|
}>;
|
|
384
401
|
// New: mapping of category -> list of skills contributing to that category
|
|
385
402
|
skillsByCategory?: Record<string, string[]>;
|
|
403
|
+
// 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[] }>;
|
|
386
405
|
// New: Flattened list of business rule selections (for appendix)
|
|
387
406
|
business_rules_all?: Array<{
|
|
388
407
|
provider: string;
|