kyd-shared-badge 0.3.121 → 0.3.123
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/connect/ConnectAccounts.tsx +442 -453
- package/src/connect/types.ts +3 -0
- package/src/ui/connect-progress.tsx +130 -50
- package/src/ui/progress-circle.tsx +35 -12
package/src/connect/types.ts
CHANGED
|
@@ -8,86 +8,166 @@ export type ConnectProgressProps = {
|
|
|
8
8
|
connectedIds: Set<string>
|
|
9
9
|
className?: string
|
|
10
10
|
layout?: 'fixed-desktop' | 'inline'
|
|
11
|
+
// Preview what the score would be if a connect/disconnect action occurred
|
|
12
|
+
previewProviderId?: string | null
|
|
13
|
+
previewAction?: 'connect' | 'disconnect' | null
|
|
14
|
+
// When a provider is selected (in the connect screen), show a pulsing gain segment
|
|
15
|
+
selectedProviderId?: string | null
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
export function ConnectProgress(props: ConnectProgressProps) {
|
|
14
|
-
const { providers, connectedIds, className, layout = 'fixed-desktop' } = props
|
|
19
|
+
const { providers, connectedIds, className, layout = 'fixed-desktop', previewProviderId, previewAction, selectedProviderId } = props
|
|
15
20
|
|
|
16
|
-
const
|
|
17
|
-
const
|
|
21
|
+
const normalizeId = (s: string) => s.toLowerCase().replace(/[^a-z]/g, '')
|
|
22
|
+
const computeNormalizedSet = (ids: Set<string>) => {
|
|
23
|
+
const norm = new Set<string>()
|
|
24
|
+
ids.forEach(id => norm.add(normalizeId(id)))
|
|
25
|
+
return norm
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const normalizedIds = useMemo(() => computeNormalizedSet(connectedIds), [connectedIds])
|
|
29
|
+
const hasGithub = normalizedIds.has('github')
|
|
30
|
+
const hasLinkedIn = normalizedIds.has('linkedin')
|
|
18
31
|
const otherConnectedCount = useMemo(() => {
|
|
19
32
|
let count = 0
|
|
20
|
-
|
|
33
|
+
normalizedIds.forEach(id => { if (id !== 'github' && id !== 'githubapp' && id !== 'linkedin') count += 1 })
|
|
21
34
|
return count
|
|
22
|
-
}, [
|
|
35
|
+
}, [normalizedIds])
|
|
36
|
+
|
|
37
|
+
const computePercent = (ids: Set<string>) => {
|
|
38
|
+
const norm = computeNormalizedSet(ids)
|
|
39
|
+
const gh = norm.has('github')
|
|
40
|
+
const gha = norm.has('githubapp')
|
|
41
|
+
const li = norm.has('linkedin')
|
|
42
|
+
let others = 0
|
|
43
|
+
norm.forEach(id => { if (id !== 'github' && id !== 'githubapp' && id !== 'linkedin') others += 1 })
|
|
44
|
+
const githubWeight = gh ? 60 : 0
|
|
45
|
+
const githubAppWeight = gha ? 20 : 0
|
|
46
|
+
const linkedinWeight = li ? 0 : 0 // LinkedIn is not included in the score
|
|
47
|
+
const othersWeight = others * 10
|
|
48
|
+
return Math.max(0, Math.min(100, githubWeight + githubAppWeight + linkedinWeight + othersWeight))
|
|
49
|
+
}
|
|
23
50
|
|
|
24
|
-
const progressPercent = useMemo(() =>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
51
|
+
const progressPercent = useMemo(() => computePercent(connectedIds), [connectedIds])
|
|
52
|
+
|
|
53
|
+
const previewPercent = useMemo(() => {
|
|
54
|
+
const pid = normalizeId(previewProviderId || '')
|
|
55
|
+
if (!pid || !previewAction) return null as number | null
|
|
56
|
+
const next = new Set<string>(Array.from(normalizedIds))
|
|
57
|
+
const has = next.has(pid)
|
|
58
|
+
if (previewAction === 'connect' && !has) next.add(pid)
|
|
59
|
+
if (previewAction === 'disconnect' && has) next.delete(pid)
|
|
60
|
+
const val = computePercent(next)
|
|
61
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
62
|
+
try {
|
|
63
|
+
// Debug details for preview behavior
|
|
64
|
+
const beforePercent = progressPercent
|
|
65
|
+
const beforeOthers = (() => {
|
|
66
|
+
let c = 0; normalizedIds.forEach(id => { if (id !== 'github' && id !== 'githubapp' && id !== 'linkedin') c += 1 })
|
|
67
|
+
return c
|
|
68
|
+
})()
|
|
69
|
+
let afterOthers = 0; next.forEach(id => { if (id !== 'github' && id !== 'githubapp' && id !== 'linkedin') afterOthers += 1 })
|
|
70
|
+
// eslint-disable-next-line no-console
|
|
71
|
+
console.log('[ConnectProgress] preview', {
|
|
72
|
+
pid,
|
|
73
|
+
previewAction,
|
|
74
|
+
hasBefore: normalizedIds.has(pid),
|
|
75
|
+
beforePercent,
|
|
76
|
+
afterPercent: val,
|
|
77
|
+
beforeOthers,
|
|
78
|
+
afterOthers,
|
|
79
|
+
normalizedIds: Array.from(normalizedIds),
|
|
80
|
+
nextIds: Array.from(next),
|
|
81
|
+
})
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
84
|
+
return val === progressPercent ? null : val
|
|
85
|
+
}, [previewProviderId, previewAction, normalizedIds, progressPercent])
|
|
30
86
|
|
|
31
87
|
const progressCopy = useMemo(() => {
|
|
32
|
-
const score = progressPercent
|
|
33
|
-
|
|
88
|
+
const score = previewPercent ?? progressPercent
|
|
89
|
+
|
|
90
|
+
if (score < 20) {
|
|
34
91
|
return {
|
|
35
|
-
headline: '
|
|
36
|
-
body: '
|
|
92
|
+
headline: 'Insufficient',
|
|
93
|
+
body: 'No meaningful data connections',
|
|
37
94
|
}
|
|
38
|
-
}
|
|
39
|
-
if (hasGithub && score < 70) {
|
|
95
|
+
} else if (score < 40) {
|
|
40
96
|
return {
|
|
41
|
-
headline: '
|
|
42
|
-
body: '
|
|
97
|
+
headline: 'Sparse',
|
|
98
|
+
body: 'Limited verified sources',
|
|
43
99
|
}
|
|
44
|
-
}
|
|
45
|
-
if (!hasGithub && score < 70) {
|
|
100
|
+
} else if (score < 60) {
|
|
46
101
|
return {
|
|
47
|
-
headline: '
|
|
48
|
-
body: '
|
|
102
|
+
headline: 'Adequate',
|
|
103
|
+
body: 'Adequate → Minimum viable data coverage',
|
|
49
104
|
}
|
|
50
|
-
}
|
|
51
|
-
if (score >= 70 && score < 90) {
|
|
105
|
+
} else if (score < 80) {
|
|
52
106
|
return {
|
|
53
|
-
headline: '
|
|
54
|
-
body: '
|
|
107
|
+
headline: 'Robust',
|
|
108
|
+
body: 'Strong, multi-source data foundation',
|
|
55
109
|
}
|
|
56
|
-
}
|
|
57
|
-
if (score >= 90) {
|
|
110
|
+
} else {
|
|
58
111
|
return {
|
|
59
|
-
headline: '
|
|
60
|
-
body: '
|
|
112
|
+
headline: 'Optimal',
|
|
113
|
+
body: 'Comprehensive, high-confidence dataset',
|
|
61
114
|
}
|
|
62
115
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
116
|
+
}, [previewPercent, progressPercent]);
|
|
117
|
+
|
|
118
|
+
const selectedPulse = useMemo(() => {
|
|
119
|
+
const sid = normalizeId(selectedProviderId || '')
|
|
120
|
+
if (!sid || sid === 'githubapp') return { from: undefined as number | undefined, to: undefined as number | undefined }
|
|
121
|
+
// Do not show if already connected
|
|
122
|
+
if (normalizedIds.has(sid)) return { from: undefined as number | undefined, to: undefined as number | undefined }
|
|
123
|
+
// Potential gain if they proceed with this provider
|
|
124
|
+
let gain = 0
|
|
125
|
+
if (sid === 'github') gain = 65
|
|
126
|
+
else if (sid === 'linkedin') gain = 15
|
|
127
|
+
else {
|
|
128
|
+
// Others always contribute +10 each (no cap)
|
|
129
|
+
const othersAlready = otherConnectedCount
|
|
130
|
+
gain = 10
|
|
68
131
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
132
|
+
const to = Math.max(0, Math.min(100, progressPercent + gain))
|
|
133
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
134
|
+
try {
|
|
135
|
+
// eslint-disable-next-line no-console
|
|
136
|
+
console.log('[ConnectProgress] selectedPulse', {
|
|
137
|
+
sid,
|
|
138
|
+
progressPercent,
|
|
139
|
+
gain,
|
|
140
|
+
to,
|
|
141
|
+
othersAlready: otherConnectedCount,
|
|
142
|
+
})
|
|
143
|
+
} catch {}
|
|
72
144
|
}
|
|
73
|
-
|
|
145
|
+
return { from: progressPercent, to }
|
|
146
|
+
}, [selectedProviderId, normalizedIds, progressPercent, otherConnectedCount])
|
|
147
|
+
|
|
148
|
+
const isPreviewing = previewPercent !== null && previewPercent !== undefined
|
|
149
|
+
const valueToShow = previewPercent ?? progressPercent
|
|
74
150
|
|
|
75
151
|
const content = (
|
|
76
152
|
<Card className={`border-[var(--icon-button-secondary)] ${className || ''}`} style={{ backgroundColor: 'var(--content-card-background)'}}>
|
|
77
|
-
<
|
|
78
|
-
<div className="flex items-
|
|
79
|
-
<ProgressCircle
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
153
|
+
<div className="p-4">
|
|
154
|
+
<div className="flex items-start gap-3">
|
|
155
|
+
<ProgressCircle
|
|
156
|
+
value={valueToShow}
|
|
157
|
+
size={64}
|
|
158
|
+
thickness={6}
|
|
159
|
+
highlightFrom={isPreviewing ? undefined : selectedPulse.from}
|
|
160
|
+
highlightTo={isPreviewing ? undefined : selectedPulse.to}
|
|
161
|
+
highlightPulse={Boolean(selectedPulse.to) && !isPreviewing}
|
|
162
|
+
/>
|
|
163
|
+
<div className="min-w-0 max-w-xs flex flex-col gap-2 items-start justify-start">
|
|
164
|
+
<div className="text-base font-semibold truncate text-[var(--text-main)]">{progressCopy.headline}</div>
|
|
165
|
+
<div className="text-sm text-[var(--text-secondary)]">
|
|
83
166
|
{progressCopy.body}
|
|
84
167
|
</div>
|
|
85
|
-
<div className="text-sm mt-1" style={{ color: 'var(--text-secondary)'}}>
|
|
86
|
-
Weighted score • {progressPercent}%
|
|
87
|
-
</div>
|
|
88
168
|
</div>
|
|
89
169
|
</div>
|
|
90
|
-
</
|
|
170
|
+
</div>
|
|
91
171
|
</Card>
|
|
92
172
|
)
|
|
93
173
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
import React from 'react'
|
|
3
|
+
import { scoreToCssVar } from '../colors'
|
|
3
4
|
|
|
4
5
|
export type ProgressCircleProps = {
|
|
5
6
|
value: number
|
|
@@ -8,10 +9,14 @@ export type ProgressCircleProps = {
|
|
|
8
9
|
className?: string
|
|
9
10
|
showLabel?: boolean
|
|
10
11
|
label?: string
|
|
12
|
+
// Optional highlight segment (e.g., to preview gain from an action)
|
|
13
|
+
highlightFrom?: number
|
|
14
|
+
highlightTo?: number
|
|
15
|
+
highlightPulse?: boolean
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
export function ProgressCircle(props: ProgressCircleProps) {
|
|
14
|
-
const { value, size = 72, thickness = 6, className, showLabel = true, label } = props
|
|
19
|
+
const { value, size = 72, thickness = 6, className, showLabel = true, label, highlightFrom, highlightTo, highlightPulse } = props
|
|
15
20
|
|
|
16
21
|
const rawId = React.useId()
|
|
17
22
|
const gradientId = React.useMemo(() => `kyd-pc-${String(rawId).replace(/:/g, '')}` , [rawId])
|
|
@@ -21,6 +26,14 @@ export function ProgressCircle(props: ProgressCircleProps) {
|
|
|
21
26
|
const circumference = 2 * Math.PI * radius
|
|
22
27
|
const dashOffset = circumference * (1 - clamped / 100)
|
|
23
28
|
|
|
29
|
+
const hasHighlight = Number.isFinite(highlightFrom as number) && Number.isFinite(highlightTo as number) && (typeof highlightFrom === 'number') && (typeof highlightTo === 'number') && highlightTo! > (highlightFrom as number)
|
|
30
|
+
const fromClamped = hasHighlight ? Math.max(0, Math.min(100, highlightFrom as number)) : 0
|
|
31
|
+
const toClamped = hasHighlight ? Math.max(0, Math.min(100, highlightTo as number)) : 0
|
|
32
|
+
const highlightLength = hasHighlight ? circumference * ((toClamped - fromClamped) / 100) : 0
|
|
33
|
+
const highlightOffset = hasHighlight ? circumference * (1 - toClamped / 100) : 0
|
|
34
|
+
|
|
35
|
+
const strokeColor = React.useMemo(() => scoreToCssVar(clamped), [clamped])
|
|
36
|
+
|
|
24
37
|
return (
|
|
25
38
|
<div className={`relative inline-flex items-center justify-center ${className || ''}`} style={{ width: size, height: size }}>
|
|
26
39
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="block">
|
|
@@ -44,34 +57,44 @@ export function ProgressCircle(props: ProgressCircleProps) {
|
|
|
44
57
|
cy={size / 2}
|
|
45
58
|
r={radius}
|
|
46
59
|
fill="none"
|
|
47
|
-
stroke={
|
|
60
|
+
stroke={strokeColor}
|
|
48
61
|
strokeWidth={thickness}
|
|
49
62
|
strokeLinecap="round"
|
|
50
63
|
strokeDasharray={circumference}
|
|
51
64
|
strokeDashoffset={dashOffset}
|
|
52
|
-
style={{ transition: 'stroke-dashoffset 800ms ease' }}
|
|
65
|
+
style={{ transition: 'stroke-dashoffset 800ms ease, stroke 400ms ease, stroke-opacity 400ms ease' }}
|
|
53
66
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
|
54
67
|
/>
|
|
68
|
+
{hasHighlight ? (
|
|
69
|
+
<circle
|
|
70
|
+
cx={size / 2}
|
|
71
|
+
cy={size / 2}
|
|
72
|
+
r={radius}
|
|
73
|
+
fill="none"
|
|
74
|
+
stroke="var(--icon-accent)"
|
|
75
|
+
strokeOpacity={0.6}
|
|
76
|
+
strokeWidth={thickness}
|
|
77
|
+
strokeLinecap="round"
|
|
78
|
+
strokeDasharray={`${highlightLength} ${circumference}`}
|
|
79
|
+
strokeDashoffset={highlightOffset}
|
|
80
|
+
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
|
81
|
+
className={highlightPulse ? 'animate-pulse' : ''}
|
|
82
|
+
/>
|
|
83
|
+
) : null}
|
|
55
84
|
{/* subtle pulsing halo */}
|
|
56
85
|
<circle
|
|
57
86
|
cx={size / 2}
|
|
58
87
|
cy={size / 2}
|
|
59
88
|
r={radius + thickness / 2}
|
|
60
89
|
fill="none"
|
|
61
|
-
stroke=
|
|
90
|
+
stroke={strokeColor}
|
|
62
91
|
strokeOpacity={0.15}
|
|
63
92
|
strokeWidth={2}
|
|
64
93
|
className="animate-pulse"
|
|
94
|
+
style={{ transition: 'stroke 400ms ease, stroke-opacity 400ms ease' }}
|
|
65
95
|
/>
|
|
66
96
|
</svg>
|
|
67
|
-
|
|
68
|
-
<div
|
|
69
|
-
className="absolute inset-0 flex items-center justify-center text-[10px] sm:text-xs font-medium"
|
|
70
|
-
style={{ color: 'var(--text-main)' }}
|
|
71
|
-
>
|
|
72
|
-
{label ? label : `${Math.round(clamped)}%`}
|
|
73
|
-
</div>
|
|
74
|
-
) : null}
|
|
97
|
+
|
|
75
98
|
</div>
|
|
76
99
|
)
|
|
77
100
|
}
|