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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kyd-shared-badge",
3
- "version": "0.3.94",
3
+ "version": "0.3.96",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -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
- const items = list.slice(0, 10);
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++) display.push(items[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
- display.push(`Others (${overflow})`);
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
- display.push(items[9]);
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) display.push(items[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
- header.appendChild(title);
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={'mt-3'}>
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
- {activeCategory ? `Category: ${activeCategory}` : 'Category'}
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 gap-y-1 text-xs" style={{ color: 'var(--text-secondary)', height: 160 }}>
254
- {skillsGrid.map((label, idx) => (
255
- <div key={idx} className="flex items-center gap-2 min-w-0">
256
- <span className={'inline-block h-2 w-2 rounded-full'} style={{ backgroundColor: label ? 'var(--icon-button-secondary)' : 'transparent', flexShrink: 0 }} />
257
- <span className="truncate" title={label}>{label || '\u00A0'}</span>
258
- </div>
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;