kyd-shared-badge 0.3.95 → 0.3.97

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.95",
3
+ "version": "0.3.97",
4
4
  "private": false,
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -22,6 +22,7 @@
22
22
  "@chatscope/chat-ui-kit-react": "^2.1.1",
23
23
  "@chatscope/chat-ui-kit-styles": "^1.4.0",
24
24
  "@knowyourdeveloper/react-bubble-chart": "^1.0.5",
25
+ "@knowyourdeveloper/react-gauge-component": "^1.1.30",
25
26
  "@radix-ui/react-slot": "^1.2.3",
26
27
  "@radix-ui/react-tooltip": "^1.2.8",
27
28
  "ai": "5.0.47",
@@ -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} skillsByCategory={assessmentResult?.graph_insights?.skillsByCategory} />
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)' }}>
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
 
3
3
  import React from 'react';
4
+ import GaugeComponent from '@knowyourdeveloper/react-gauge-component';
4
5
  import BusinessRuleLink from './BusinessRuleLink';
5
6
  import { FiInfo } from 'react-icons/fi';
6
- import { hexToRgba, scoreToColorHex, scoreToCssVar, clampPercent } from '../colors';
7
+ import { hexToRgba, scoreToColorHex, scoreToCssVar, clampPercent, red, yellow, green } from '../colors';
7
8
 
8
9
  type TopMover = { label?: string; uid?: string };
9
10
 
@@ -28,13 +29,7 @@ export default function GaugeCard({
28
29
  }) {
29
30
  const pct = clampPercent(percent);
30
31
  const displayLabel = label || '';
31
- // Use a fixed internal coordinate system and scale the SVG to container for responsiveness
32
- const size = 280;
33
- const strokeWidth = 32;
34
- const radius = (size - strokeWidth) / 2;
35
- const circumference = Math.PI * radius;
36
- const progress = pct / 100;
37
- const dash = circumference * progress;
32
+ // Keep card tinting consistent with score
38
33
  const progressColor = scoreToCssVar(pct);
39
34
  const headerTint = hexToRgba(scoreToColorHex(pct), 0.06);
40
35
 
@@ -69,11 +64,23 @@ export default function GaugeCard({
69
64
  </div>
70
65
  <div className="flex-grow flex flex-col items-center justify-center" style={{ minHeight: 200 }}>
71
66
  <div className="relative group" style={{ width: '100%', aspectRatio: '2 / 1', maxWidth: 360 }}>
72
- <svg width={'100%'} height={'100%'} viewBox={`0 0 ${size} ${size/2}`} preserveAspectRatio={'xMidYMid meet'}>
73
- <path d={`M ${strokeWidth/2} ${size/2} A ${radius} ${radius} 0 0 1 ${size-strokeWidth/2} ${size/2}`} stroke={'var(--icon-button-secondary)'} strokeWidth={strokeWidth} fill="none" strokeLinecap="round" />
74
- <path d={`M ${strokeWidth/2} ${size/2} A ${radius} ${radius} 0 0 1 ${size-strokeWidth/2} ${size/2}`} stroke={progressColor} strokeWidth={strokeWidth} fill="none" strokeLinecap="round" strokeDasharray={`${dash},${circumference}`} />
75
- <line x1={size/2} y1={size/2} x2={size/2 + radius * Math.cos(Math.PI * progress - Math.PI)} y2={size/2 + radius * Math.sin(Math.PI * progress - Math.PI)} stroke={'var(--text-main)'} strokeWidth="2" />
76
- </svg>
67
+ <GaugeComponent
68
+ type="semicircle"
69
+ style={{ width: '100%', height: '100%' }}
70
+ value={pct}
71
+ minValue={0}
72
+ maxValue={100}
73
+ arc={{
74
+ padding: 0.02,
75
+ // Explicit subArcs ensure left->right map: red -> yellow -> green
76
+ subArcs: [
77
+ { limit: 33, color: red },
78
+ { limit: 66, color: yellow },
79
+ { limit: 100, color: green },
80
+ ],
81
+ }}
82
+ pointer={{ type: 'blob', elastic: true, animationDelay: 0 }}
83
+ />
77
84
  {(tooltipText || description) && (
78
85
  <div className="hidden group-hover:block absolute z-30 left-1/2 -translate-x-1/2 top-full mt-2 w-80">
79
86
  <div style={{ background: 'var(--content-card-background)', border: '1px solid var(--icon-button-secondary)', color: 'var(--text-main)', padding: 10, borderRadius: 6 }}>
@@ -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,24 +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, skillsMeta, headless }: { skillsCategoryRadar?: SkillsRadarPoint[]; skillsByCategory?: Record<string, string[]>; skillsMeta?: Record<string, { presence?: 'certified' | 'observed' | 'self-reported'; years?: number }>; 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
- console.log('skillsRadarLimited', skillsRadarLimited);
67
- console.log('skillsByCategory', skillsByCategory);
68
60
  useEffect(() => {
69
61
  if (typeof window !== 'undefined') {
70
62
  const id = window.setTimeout(() => {
@@ -74,6 +66,23 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
74
66
  }
75
67
  }, []);
76
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
+
77
86
  // ratio drives size: average of observed/self_reported/certified
78
87
  const bubbles = useMemo(() => {
79
88
  const seriesAvg = (d: SkillsRadarPoint): number => {
@@ -132,25 +141,36 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
132
141
  // Enrich with meta and sort by years desc
133
142
  const enriched = list.map((name) => {
134
143
  const meta = skillsMeta?.[name] || {};
135
- return { name, years: Number(meta.years || 0), presence: (meta.presence as string) || '' };
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 };
136
146
  }).sort((a, b) => b.years - a.years || a.name.localeCompare(b.name));
137
147
  const items = enriched.slice(0, 10);
138
148
  const overflow = list.length - items.length;
139
- const display: Array<{ label: string; years?: number; presence?: string } | ''> = [];
149
+ const display: Array<{ label: string; years?: number; presence?: string; sources?: string[] } | ''> = [];
140
150
  for (let i = 0; i < Math.min(9, items.length); i++) {
141
151
  const it = items[i];
142
152
  const label = it.name ? `${it.name}` : '';
143
- display.push({ label, years: it.years, presence: it.presence });
153
+ display.push({ label, years: it.years, presence: it.presence, sources: it.sources });
144
154
  }
145
155
  if (list.length > 10) {
146
- display.push({ label: `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 });
147
167
  } else if (items.length >= 10) {
148
168
  const it = items[9];
149
- display.push({ label: it?.name || '', years: it?.years, presence: it?.presence });
169
+ display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, sources: it?.sources });
150
170
  } else {
151
171
  if (items.length > 9) {
152
172
  const it = items[9];
153
- display.push({ label: it?.name || '', years: it?.years, presence: it?.presence });
173
+ display.push({ label: it?.name || '', years: it?.years, presence: it?.presence, sources: it?.sources });
154
174
  }
155
175
  }
156
176
  while (display.length < 10) display.push('');
@@ -168,8 +188,94 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
168
188
  return map;
169
189
  }, [bubbles]);
170
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]);
209
+
171
210
  if (!hasRadar) return null;
172
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
+
173
279
  return (
174
280
  <div className={'kyd-avoid-break'}>
175
281
  <div ref={containerRef} style={{ width: '100%', height: 340, position: 'relative' }}>
@@ -233,28 +339,37 @@ export default function SkillsBubble({ skillsCategoryRadar, skillsByCategory, sk
233
339
  }}
234
340
  />
235
341
  </div>
236
- <div className={'mt-3'}>
342
+ <div className='mt-8'>
237
343
  <div ref={legendRef} className={'kyd-avoid-break'} style={{ position: 'relative', breakInside: 'avoid', pageBreakInside: 'avoid' as unknown as undefined }}>
238
344
  <div className="mb-2 text-xs font-medium" style={{ color: 'var(--text-main)' }}>
239
- {activeCategory ? `Category: ${activeCategory}` : 'Category'}
240
- {activeCategory ? ` • ${categoryPercentMap[activeCategory] ?? 0}%` : ''}
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}
241
361
  </div>
242
- <div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs" style={{ color: 'var(--text-secondary)', height: 160 }}>
243
- {skillsGrid.map((entry, idx) => (
244
- <div key={idx} className="flex items-center gap-2 min-w-0">
245
- <span className={'inline-block h-2 w-2 rounded-full'} style={{ backgroundColor: entry ? 'var(--icon-button-secondary)' : 'transparent', flexShrink: 0 }} />
246
- <span className="shrink-0 opacity-70">{idx + 1}.</span>
247
- {entry && typeof entry !== 'string' ? (
248
- <span className="truncate" title={entry.label}>
249
- {entry.label}
250
- {entry.years ? ` • ${entry.years}y` : ''}
251
- {entry.presence ? ` • ${entry.presence}` : ''}
252
- </span>
253
- ) : (
254
- <span className="truncate">{typeof entry === 'string' ? entry : '\u00A0'}</span>
255
- )}
256
- </div>
257
- ))}
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>
258
373
  </div>
259
374
  {!headless && <TooltipBox state={legendTooltip} />}
260
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;
@@ -384,7 +401,7 @@ export interface GraphInsightsPayload {
384
401
  // New: mapping of category -> list of skills contributing to that category
385
402
  skillsByCategory?: Record<string, string[]>;
386
403
  // New: per-skill metadata used by UI (e.g., presence label, experience years)
387
- skillsMeta?: Record<string, { presence?: 'certified' | 'observed' | 'self-reported'; years?: number }>;
404
+ skillsMeta?: Record<string, { presence?: 'certified' | 'observed' | 'self-reported'; years?: number; sources?: string[] }>;
388
405
  // New: Flattened list of business rule selections (for appendix)
389
406
  business_rules_all?: Array<{
390
407
  provider: string;