Skip to content

Commit ac98e03

Browse files
Add ActivityRing component and update to program owner activity (#3342)
Co-authored-by: Steven Tey <stevensteel97@gmail.com>
1 parent 19ee424 commit ac98e03

File tree

3 files changed

+340
-40
lines changed

3 files changed

+340
-40
lines changed
Lines changed: 69 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { usePartnerCrossProgramSummary } from "@/lib/swr/use-partner-cross-program-summary";
4-
import { UserCheck, UserXmark } from "@dub/ui";
4+
import { ActivityRing, User, UserCheck, UserXmark } from "@dub/ui";
55

66
export function PartnerCrossProgramSummary({
77
partnerId,
@@ -12,49 +12,78 @@ export function PartnerCrossProgramSummary({
1212
partnerId,
1313
});
1414

15-
const crossProgramMetrics = [
16-
{
17-
icon: UserCheck,
18-
text: "Marked as trustworthy",
19-
total: crossProgramSummary?.totalPrograms,
20-
value: crossProgramSummary?.trustedPrograms,
21-
},
22-
{
23-
icon: UserXmark,
24-
text: "Removed from programs",
25-
total: crossProgramSummary?.totalPrograms,
26-
value: crossProgramSummary?.removedPrograms,
27-
},
28-
];
15+
if (isLoading || !crossProgramSummary) {
16+
return <LoadingSkeleton />;
17+
}
18+
19+
const { totalPrograms, trustedPrograms, removedPrograms } =
20+
crossProgramSummary;
2921

3022
return (
31-
<>
32-
{crossProgramMetrics.map((item) => (
33-
<div
34-
key={item.text}
35-
className="flex items-center justify-between gap-2"
36-
>
37-
<div className="flex flex-grow items-center gap-2 sm:w-64">
38-
<item.icon className="size-4 shrink-0" />
39-
<span className="text-xs font-medium text-neutral-700">
40-
{item.text}
41-
</span>
42-
</div>
23+
<div className="flex items-center gap-3">
24+
<ActivityRing
25+
positiveValue={trustedPrograms}
26+
negativeValue={removedPrograms}
27+
positiveIcon={UserCheck}
28+
negativeIcon={UserXmark}
29+
neutralIcon={User}
30+
/>
31+
<div className="flex min-w-0 grow flex-col gap-[5px]">
32+
<StatRow
33+
label="Marked as trustworthy"
34+
value={trustedPrograms}
35+
total={totalPrograms}
36+
/>
37+
<StatRow
38+
label="Removed from programs"
39+
value={removedPrograms}
40+
total={totalPrograms}
41+
/>
42+
</div>
43+
</div>
44+
);
45+
}
4346

44-
<div className="w-10">
45-
{isLoading || !crossProgramSummary ? (
46-
<div className="h-4 w-full animate-pulse justify-end rounded bg-neutral-200" />
47-
) : (
48-
<div className="flex items-center justify-end gap-1 text-xs font-medium">
49-
<span className="text-neutral-700">{item.value || 0}</span>
50-
<span className="whitespace-nowrap text-neutral-400">
51-
of {item.total || 0}
52-
</span>
53-
</div>
54-
)}
47+
function StatRow({
48+
label,
49+
value,
50+
total,
51+
}: {
52+
label: string;
53+
value: number;
54+
total: number;
55+
}) {
56+
return (
57+
<div className="flex items-center justify-between gap-6">
58+
<span className="text-xs font-medium text-neutral-700">{label}</span>
59+
<div className="flex items-center gap-1 text-xs">
60+
<span className="font-semibold text-neutral-800">{value}</span>
61+
<span className="font-medium text-neutral-500">of {total}</span>
62+
</div>
63+
</div>
64+
);
65+
}
66+
67+
function LoadingSkeleton() {
68+
return (
69+
<div className="flex items-center gap-3">
70+
<div className="size-10 shrink-0 animate-pulse rounded-full bg-neutral-200" />
71+
<div className="flex min-w-0 grow flex-col gap-[5px]">
72+
<div className="flex items-center justify-between gap-6">
73+
<div className="h-4 w-28 animate-pulse rounded bg-neutral-200" />
74+
<div className="flex items-center gap-1">
75+
<div className="h-4 w-4 animate-pulse rounded bg-neutral-200" />
76+
<div className="h-4 w-7 animate-pulse rounded bg-neutral-200" />
77+
</div>
78+
</div>
79+
<div className="flex items-center justify-between gap-6">
80+
<div className="h-4 w-32 animate-pulse rounded bg-neutral-200" />
81+
<div className="flex items-center gap-1">
82+
<div className="h-4 w-4 animate-pulse rounded bg-neutral-200" />
83+
<div className="h-4 w-7 animate-pulse rounded bg-neutral-200" />
5584
</div>
5685
</div>
57-
))}
58-
</>
86+
</div>
87+
</div>
5988
);
6089
}

packages/ui/src/activity-ring.tsx

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { cn } from "@dub/utils";
2+
import { ComponentType, SVGProps, useMemo } from "react";
3+
4+
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
5+
6+
interface ActivityRingProps {
7+
/** Value for the positive/trustworthy side */
8+
positiveValue: number;
9+
/** Value for the negative/removed side */
10+
negativeValue: number;
11+
/** Size of the ring in pixels (default: 40) */
12+
size?: number;
13+
/** Icon to show when positive leads */
14+
positiveIcon?: IconComponent;
15+
/** Icon to show when negative leads */
16+
negativeIcon?: IconComponent;
17+
/** Icon to show when neutral (tie) */
18+
neutralIcon?: IconComponent;
19+
className?: string;
20+
}
21+
22+
const COLORS = {
23+
positive: "#00C951", // green ring
24+
negative: "#FB2C36", // red ring
25+
neutral: "#e5e5e5", // neutral-200 for rings
26+
positiveIcon: "#166534", // green-800
27+
negativeIcon: "#991b1b", // red-800
28+
neutralIcon: "#262626", // neutral-800
29+
};
30+
31+
// Gap angle in degrees at each connection point
32+
const GAP_ANGLE = 15;
33+
34+
// Minimum arc sweep to ensure visibility when value > 0
35+
const MIN_ARC_DEGREES = 30;
36+
37+
function polarToCartesian(
38+
cx: number,
39+
cy: number,
40+
r: number,
41+
angleDeg: number,
42+
): { x: number; y: number } {
43+
const angleRad = ((angleDeg - 90) * Math.PI) / 180;
44+
return {
45+
x: cx + r * Math.cos(angleRad),
46+
y: cy + r * Math.sin(angleRad),
47+
};
48+
}
49+
50+
function describeArc(
51+
cx: number,
52+
cy: number,
53+
r: number,
54+
startAngle: number,
55+
endAngle: number,
56+
): string {
57+
const start = polarToCartesian(cx, cy, r, startAngle);
58+
const end = polarToCartesian(cx, cy, r, endAngle);
59+
const sweep = endAngle - startAngle;
60+
const largeArcFlag = sweep > 180 ? 1 : 0;
61+
62+
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} 1 ${end.x} ${end.y}`;
63+
}
64+
65+
// Creates a filled arc segment (donut slice) between outer and inner radius
66+
function describeFilledArc(
67+
cx: number,
68+
cy: number,
69+
outerRadius: number,
70+
innerRadius: number,
71+
startAngle: number,
72+
endAngle: number,
73+
): string {
74+
const outerStart = polarToCartesian(cx, cy, outerRadius, startAngle);
75+
const outerEnd = polarToCartesian(cx, cy, outerRadius, endAngle);
76+
const innerStart = polarToCartesian(cx, cy, innerRadius, startAngle);
77+
const innerEnd = polarToCartesian(cx, cy, innerRadius, endAngle);
78+
const sweep = endAngle - startAngle;
79+
const largeArcFlag = sweep > 180 ? 1 : 0;
80+
81+
return [
82+
`M ${outerStart.x} ${outerStart.y}`, // Start at outer arc
83+
`A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${outerEnd.x} ${outerEnd.y}`, // Outer arc
84+
`L ${innerEnd.x} ${innerEnd.y}`, // Line to inner arc
85+
`A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${innerStart.x} ${innerStart.y}`, // Inner arc (reverse)
86+
"Z", // Close path
87+
].join(" ");
88+
}
89+
90+
type RingState = "positive" | "negative" | "neutral";
91+
92+
export function ActivityRing({
93+
positiveValue,
94+
negativeValue,
95+
size = 40,
96+
positiveIcon: PositiveIcon,
97+
negativeIcon: NegativeIcon,
98+
neutralIcon: NeutralIcon,
99+
className,
100+
}: ActivityRingProps) {
101+
const state: RingState = useMemo(() => {
102+
if (positiveValue > negativeValue) return "positive";
103+
if (negativeValue > positiveValue) return "negative";
104+
return "neutral";
105+
}, [positiveValue, negativeValue]);
106+
107+
// Calculate arc angles based on ratio
108+
const { positiveArc, negativeArc } = useMemo(() => {
109+
const total = positiveValue + negativeValue;
110+
111+
// Available sweep angle (360 - 2 gaps)
112+
const availableSweep = 360 - GAP_ANGLE * 2;
113+
114+
if (total === 0) {
115+
// Neutral: equal arcs at 50% each
116+
const halfSweep = availableSweep / 2;
117+
return {
118+
// Right side: from top going clockwise to bottom
119+
positiveArc: { start: GAP_ANGLE / 2, end: GAP_ANGLE / 2 + halfSweep },
120+
// Left side: from bottom going clockwise to top
121+
negativeArc: {
122+
start: 180 + GAP_ANGLE / 2,
123+
end: 180 + GAP_ANGLE / 2 + halfSweep,
124+
},
125+
};
126+
}
127+
128+
const positiveRatio = positiveValue / total;
129+
130+
// Calculate sweeps with minimum visibility
131+
let positiveSweep = positiveRatio * availableSweep;
132+
let negativeSweep = (1 - positiveRatio) * availableSweep;
133+
134+
// Ensure minimum visibility for non-zero values
135+
if (positiveValue > 0 && positiveSweep < MIN_ARC_DEGREES) {
136+
positiveSweep = MIN_ARC_DEGREES;
137+
negativeSweep = availableSweep - MIN_ARC_DEGREES;
138+
}
139+
if (negativeValue > 0 && negativeSweep < MIN_ARC_DEGREES) {
140+
negativeSweep = MIN_ARC_DEGREES;
141+
positiveSweep = availableSweep - MIN_ARC_DEGREES;
142+
}
143+
144+
return {
145+
// Right side: from top going clockwise
146+
positiveArc: {
147+
start: GAP_ANGLE / 2,
148+
end: GAP_ANGLE / 2 + positiveSweep,
149+
},
150+
// Left side: from where positive ends + gap, going clockwise
151+
negativeArc: {
152+
start: GAP_ANGLE / 2 + positiveSweep + GAP_ANGLE,
153+
end: GAP_ANGLE / 2 + positiveSweep + GAP_ANGLE + negativeSweep,
154+
},
155+
};
156+
}, [positiveValue, negativeValue]);
157+
158+
const center = size / 2;
159+
const strokeWidth = 3;
160+
const outerRadius = (size - strokeWidth) / 2; // Stroke centered on this radius
161+
const fillOuterRadius = outerRadius - strokeWidth / 2; // Inner edge of stroke
162+
const fillInnerRadius = size * 0.28; // Inner radius for filled area
163+
164+
const IconComponent = useMemo(() => {
165+
switch (state) {
166+
case "positive":
167+
return PositiveIcon;
168+
case "negative":
169+
return NegativeIcon;
170+
default:
171+
return NeutralIcon;
172+
}
173+
}, [state, PositiveIcon, NegativeIcon, NeutralIcon]);
174+
175+
const iconColor = useMemo(() => {
176+
switch (state) {
177+
case "positive":
178+
return COLORS.positiveIcon;
179+
case "negative":
180+
return COLORS.negativeIcon;
181+
default:
182+
return COLORS.neutralIcon;
183+
}
184+
}, [state]);
185+
186+
// Always use green and red colors
187+
const positiveColor = COLORS.positive;
188+
const negativeColor = COLORS.negative;
189+
190+
return (
191+
<div
192+
className={cn("relative shrink-0", className)}
193+
style={{ width: size, height: size }}
194+
>
195+
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
196+
{/* Filled arc for positive (behind stroke) */}
197+
{positiveValue > 0 && (
198+
<path
199+
d={describeFilledArc(
200+
center,
201+
center,
202+
fillOuterRadius,
203+
fillInnerRadius,
204+
positiveArc.start,
205+
positiveArc.end,
206+
)}
207+
fill={COLORS.positive}
208+
opacity={0.1}
209+
/>
210+
)}
211+
212+
{/* Filled arc for negative (behind stroke) */}
213+
{negativeValue > 0 && (
214+
<path
215+
d={describeFilledArc(
216+
center,
217+
center,
218+
fillOuterRadius,
219+
fillInnerRadius,
220+
negativeArc.start,
221+
negativeArc.end,
222+
)}
223+
fill={COLORS.negative}
224+
opacity={0.1}
225+
/>
226+
)}
227+
228+
{/* Positive arc stroke (right side) */}
229+
<path
230+
d={describeArc(
231+
center,
232+
center,
233+
outerRadius,
234+
positiveArc.start,
235+
positiveArc.end,
236+
)}
237+
stroke={positiveColor}
238+
strokeWidth={strokeWidth}
239+
fill="none"
240+
strokeLinecap="round"
241+
/>
242+
243+
{/* Negative arc stroke (left side) */}
244+
<path
245+
d={describeArc(
246+
center,
247+
center,
248+
outerRadius,
249+
negativeArc.start,
250+
negativeArc.end,
251+
)}
252+
stroke={negativeColor}
253+
strokeWidth={strokeWidth}
254+
fill="none"
255+
strokeLinecap="round"
256+
/>
257+
</svg>
258+
259+
{/* Centered icon */}
260+
{IconComponent && (
261+
<div
262+
className="absolute inset-0 flex items-center justify-center"
263+
style={{ color: iconColor }}
264+
>
265+
<IconComponent className="size-3.5" />
266+
</div>
267+
)}
268+
</div>
269+
);
270+
}

0 commit comments

Comments
 (0)