Skip to content

Commit ecf41d5

Browse files
authored
enhancement(ui): add graceful no-data states for activity components (JhaSourav07#959)
* wip: add no-data state placeholders * enhancement(ui): add graceful no-data states for activity components
1 parent 2bb85a4 commit ecf41d5

2 files changed

Lines changed: 178 additions & 164 deletions

File tree

components/dashboard/CommitClock.tsx

Lines changed: 147 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export default function CommitClock({ data }: { data: CommitClockData[] }) {
1313

1414
// Find the peak day index
1515
const peakIndex = data.reduce((peak, d, i) => (d.commits > data[peak].commits ? i : peak), 0);
16+
const hasData = data.length > 0 && data.some((d) => d.commits > 0);
1617

1718
return (
1819
<motion.div
@@ -30,16 +31,17 @@ export default function CommitClock({ data }: { data: CommitClockData[] }) {
3031
</div>
3132

3233
<div className="relative w-[280px] h-[280px] flex items-center justify-center mt-4">
33-
<svg width="280" height="280" className="overflow-visible rotate-[-90deg]">
34-
<defs>
35-
<filter id="spoke-glow" x="-50%" y="-50%" width="200%" height="200%">
36-
<feGaussianBlur stdDeviation="3" result="blur" />
37-
<feComposite in="SourceGraphic" in2="blur" operator="over" />
38-
</filter>
39-
</defs>
40-
41-
<style>
42-
{`
34+
{hasData ? (
35+
<svg width="280" height="280" className="overflow-visible rotate-[-90deg]">
36+
<defs>
37+
<filter id="spoke-glow" x="-50%" y="-50%" width="200%" height="200%">
38+
<feGaussianBlur stdDeviation="3" result="blur" />
39+
<feComposite in="SourceGraphic" in2="blur" operator="over" />
40+
</filter>
41+
</defs>
42+
43+
<style>
44+
{`
4345
:root {
4446
--peak-spoke: #111111;
4547
--peak-dot: rgba(17,17,17,0.7);
@@ -52,143 +54,148 @@ export default function CommitClock({ data }: { data: CommitClockData[] }) {
5254
--peak-label: #ffffff;
5355
}
5456
`}
55-
</style>
56-
57-
{/* Base ring */}
58-
<circle
59-
cx={cx}
60-
cy={cy}
61-
r={radius}
62-
fill="none"
63-
stroke="rgba(120,120,120,0.15)"
64-
strokeWidth="18"
65-
/>
66-
67-
{/* Outer boundary ring */}
68-
<circle
69-
cx={cx}
70-
cy={cy}
71-
r={radius + maxSpokeLength + 8}
72-
fill="none"
73-
stroke="rgba(120,120,120,0.22)"
74-
strokeWidth="1"
75-
/>
76-
77-
{/* Subtle background dial (35 ticks = 7 days * 5 sub-intervals) */}
78-
{Array.from({ length: 35 }).map((_, i) => {
79-
const angle = (i * 360) / 35;
80-
const rad = (angle * Math.PI) / 180;
81-
const isMain = i % 5 === 0;
82-
83-
// Main spokes get a full-length faint track, interval ticks are short
84-
const tickLength = isMain ? maxSpokeLength : 4;
85-
const innerOffset = isMain ? 0 : 4;
86-
87-
return (
88-
<line
89-
key={`bg-tick-${i}`}
90-
x1={r4(cx + (radius + innerOffset) * Math.cos(rad))}
91-
y1={r4(cy + (radius + innerOffset) * Math.sin(rad))}
92-
x2={r4(cx + (radius + tickLength) * Math.cos(rad))}
93-
y2={r4(cy + (radius + tickLength) * Math.sin(rad))}
94-
stroke={isMain ? 'rgba(120,120,120,0.28)' : 'rgba(120,120,120,0.14)'}
95-
strokeWidth={isMain ? '4' : '2'}
96-
strokeLinecap="round"
97-
/>
98-
);
99-
})}
100-
101-
{/* Spokes — one per day of week */}
102-
{data.map((d, i) => {
103-
const angle = (i * 360) / 7;
104-
const length = Math.max((d.commits / maxCommits) * maxSpokeLength, 4);
105-
const isHigh = d.commits > maxCommits * 0.7;
106-
const isPeak = i === peakIndex && d.commits > 0;
107-
const rad = (angle * Math.PI) / 180;
108-
109-
// Scale stroke width: 3px base, up to 6px for peak
110-
const strokeW = isPeak ? 6 : isHigh ? 5 : 4;
111-
112-
const x1 = r4(cx + radius * Math.cos(rad));
113-
const y1 = r4(cy + radius * Math.sin(rad));
114-
const x2 = r4(cx + (radius + length) * Math.cos(rad));
115-
const y2 = r4(cy + (radius + length) * Math.sin(rad));
116-
const labelX = r4(cx + (radius + maxSpokeLength + 14) * Math.cos(rad));
117-
const labelY = r4(cy + (radius + maxSpokeLength + 14) * Math.sin(rad));
118-
119-
const spokeColor = isPeak
120-
? 'var(--peak-spoke)'
121-
: isHigh
122-
? 'rgba(160,160,160,0.85)'
123-
: 'rgba(110,110,110,0.55)';
124-
125-
const dotColor = isPeak
126-
? 'var(--peak-dot)'
127-
: isHigh
128-
? 'rgba(180,180,180,0.75)'
129-
: 'rgba(120,120,120,0.55)';
130-
131-
const labelColor = isPeak
132-
? 'var(--peak-label)'
133-
: isHigh
134-
? 'rgba(180,180,180,0.75)'
135-
: 'rgba(120,120,120,0.6)';
136-
return (
137-
<motion.g
138-
key={d.day}
139-
initial={{ opacity: 0 }}
140-
animate={{ opacity: 1 }}
141-
transition={{ delay: i * 0.08, duration: 0.3 }}
142-
>
57+
</style>
58+
59+
{/* Base ring */}
60+
<circle
61+
cx={cx}
62+
cy={cy}
63+
r={radius}
64+
fill="none"
65+
stroke="rgba(120,120,120,0.15)"
66+
strokeWidth="18"
67+
/>
68+
69+
{/* Outer boundary ring */}
70+
<circle
71+
cx={cx}
72+
cy={cy}
73+
r={radius + maxSpokeLength + 8}
74+
fill="none"
75+
stroke="rgba(120,120,120,0.22)"
76+
strokeWidth="1"
77+
/>
78+
79+
{/* Subtle background dial (35 ticks = 7 days * 5 sub-intervals) */}
80+
{Array.from({ length: 35 }).map((_, i) => {
81+
const angle = (i * 360) / 35;
82+
const rad = (angle * Math.PI) / 180;
83+
const isMain = i % 5 === 0;
84+
85+
// Main spokes get a full-length faint track, interval ticks are short
86+
const tickLength = isMain ? maxSpokeLength : 4;
87+
const innerOffset = isMain ? 0 : 4;
88+
89+
return (
14390
<line
144-
x1={x1}
145-
y1={y1}
146-
x2={x2}
147-
y2={y2}
148-
stroke={spokeColor}
149-
strokeWidth={strokeW}
91+
key={`bg-tick-${i}`}
92+
x1={r4(cx + (radius + innerOffset) * Math.cos(rad))}
93+
y1={r4(cy + (radius + innerOffset) * Math.sin(rad))}
94+
x2={r4(cx + (radius + tickLength) * Math.cos(rad))}
95+
y2={r4(cy + (radius + tickLength) * Math.sin(rad))}
96+
stroke={isMain ? 'rgba(120,120,120,0.28)' : 'rgba(120,120,120,0.14)'}
97+
strokeWidth={isMain ? '4' : '2'}
15098
strokeLinecap="round"
151-
filter={isPeak ? 'url(#spoke-glow)' : undefined}
15299
/>
153-
154-
{/* Dot at the end of each spoke */}
155-
{d.commits > 0 && (
156-
<circle
157-
cx={x2}
158-
cy={y2}
159-
r={isPeak ? 2.2 : 2}
160-
fill={dotColor}
100+
);
101+
})}
102+
103+
{/* Spokes — one per day of week */}
104+
{data.map((d, i) => {
105+
const angle = (i * 360) / 7;
106+
const length = Math.max((d.commits / maxCommits) * maxSpokeLength, 4);
107+
const isHigh = d.commits > maxCommits * 0.7;
108+
const isPeak = i === peakIndex && d.commits > 0;
109+
const rad = (angle * Math.PI) / 180;
110+
111+
// Scale stroke width: 3px base, up to 6px for peak
112+
const strokeW = isPeak ? 6 : isHigh ? 5 : 4;
113+
114+
const x1 = r4(cx + radius * Math.cos(rad));
115+
const y1 = r4(cy + radius * Math.sin(rad));
116+
const x2 = r4(cx + (radius + length) * Math.cos(rad));
117+
const y2 = r4(cy + (radius + length) * Math.sin(rad));
118+
const labelX = r4(cx + (radius + maxSpokeLength + 14) * Math.cos(rad));
119+
const labelY = r4(cy + (radius + maxSpokeLength + 14) * Math.sin(rad));
120+
121+
const spokeColor = isPeak
122+
? 'var(--peak-spoke)'
123+
: isHigh
124+
? 'rgba(160,160,160,0.85)'
125+
: 'rgba(110,110,110,0.55)';
126+
127+
const dotColor = isPeak
128+
? 'var(--peak-dot)'
129+
: isHigh
130+
? 'rgba(180,180,180,0.75)'
131+
: 'rgba(120,120,120,0.55)';
132+
133+
const labelColor = isPeak
134+
? 'var(--peak-label)'
135+
: isHigh
136+
? 'rgba(180,180,180,0.75)'
137+
: 'rgba(120,120,120,0.6)';
138+
return (
139+
<motion.g
140+
key={d.day}
141+
initial={{ opacity: 0 }}
142+
animate={{ opacity: 1 }}
143+
transition={{ delay: i * 0.08, duration: 0.3 }}
144+
>
145+
<line
146+
x1={x1}
147+
y1={y1}
148+
x2={x2}
149+
y2={y2}
150+
stroke={spokeColor}
151+
strokeWidth={strokeW}
152+
strokeLinecap="round"
161153
filter={isPeak ? 'url(#spoke-glow)' : undefined}
162154
/>
163-
)}
164-
165-
{/* Day and Commit Count Label (stacked vertically via tspan to avoid side overlaps) */}
166-
<text
167-
x={labelX}
168-
y={labelY}
169-
fill={isPeak ? 'var(--peak-label)' : 'rgba(120,120,120,0.55)'}
170-
fontSize="9"
171-
fontWeight={isPeak ? '700' : '400'}
172-
textAnchor="middle"
173-
transform={`rotate(90, ${labelX}, ${labelY})`}
174-
>
175-
<tspan x={labelX} dy="-2">
176-
{d.day}
177-
</tspan>
178-
<tspan
155+
156+
{/* Dot at the end of each spoke */}
157+
{d.commits > 0 && (
158+
<circle
159+
cx={x2}
160+
cy={y2}
161+
r={isPeak ? 2.2 : 2}
162+
fill={dotColor}
163+
filter={isPeak ? 'url(#spoke-glow)' : undefined}
164+
/>
165+
)}
166+
167+
{/* Day and Commit Count Label (stacked vertically via tspan to avoid side overlaps) */}
168+
<text
179169
x={labelX}
180-
dy="10"
181-
fill={isPeak ? 'var(--peak-label)' : labelColor}
182-
fontSize="7"
183-
fontWeight="700"
170+
y={labelY}
171+
fill={isPeak ? 'var(--peak-label)' : 'rgba(120,120,120,0.55)'}
172+
fontSize="9"
173+
fontWeight={isPeak ? '700' : '400'}
174+
textAnchor="middle"
175+
transform={`rotate(90, ${labelX}, ${labelY})`}
184176
>
185-
{d.commits}
186-
</tspan>
187-
</text>
188-
</motion.g>
189-
);
190-
})}
191-
</svg>
177+
<tspan x={labelX} dy="-2">
178+
{d.day}
179+
</tspan>
180+
<tspan
181+
x={labelX}
182+
dy="10"
183+
fill={isPeak ? 'var(--peak-label)' : labelColor}
184+
fontSize="7"
185+
fontWeight="700"
186+
>
187+
{d.commits}
188+
</tspan>
189+
</text>
190+
</motion.g>
191+
);
192+
})}
193+
</svg>
194+
) : (
195+
<div className="h-[280px] flex items-center justify-center w-full rounded-lg border border-dashed border-black/10 dark:border-[rgba(255,255,255,0.08)] text-sm text-[#A1A1AA]">
196+
No recent activity to display
197+
</div>
198+
)}
192199

193200
{/* Center label */}
194201
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">

components/dashboard/Heatmap.tsx

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export default function Heatmap({ data }: { data: ActivityData[] }) {
2626
}
2727

2828
const naturalWidth = weeks.length * (CELL + GAP) - GAP;
29+
const hasData = data.length > 0 && data.some((d) => d.count > 0);
2930

3031
// Recalculate scale whenever the card resizes
3132
useEffect(() => {
@@ -82,32 +83,38 @@ export default function Heatmap({ data }: { data: ActivityData[] }) {
8283
</div>
8384

8485
{/* Scale wrapper */}
85-
<div ref={containerRef} className="w-full overflow-hidden">
86-
<div
87-
style={{
88-
width: naturalWidth,
89-
transformOrigin: 'top left',
90-
transform: `scale(${scale})`,
91-
height: (7 * (CELL + GAP) - GAP) * scale,
92-
}}
93-
>
94-
<div className="flex " style={{ gap: GAP }}>
95-
{weeks.map((week, wIndex) => (
96-
<div key={wIndex} className="flex flex-col" style={{ gap: GAP }}>
97-
{week.map((day, dIndex) => (
98-
<div
99-
key={dIndex}
100-
onMouseEnter={(e) => handleMouseEnter(e, day)}
101-
onMouseLeave={handleMouseLeave}
102-
className={`rounded-sm cursor-pointer transition-all duration-150 hover:brightness-125 hover:scale-125 ${getIntensityColor(day.intensity)}`}
103-
style={{ width: CELL, height: CELL }}
104-
/>
105-
))}
106-
</div>
107-
))}
86+
{hasData ? (
87+
<div ref={containerRef} className="w-full overflow-hidden">
88+
<div
89+
style={{
90+
width: naturalWidth,
91+
transformOrigin: 'top left',
92+
transform: `scale(${scale})`,
93+
height: (7 * (CELL + GAP) - GAP) * scale,
94+
}}
95+
>
96+
<div className="flex " style={{ gap: GAP }}>
97+
{weeks.map((week, wIndex) => (
98+
<div key={wIndex} className="flex flex-col" style={{ gap: GAP }}>
99+
{week.map((day, dIndex) => (
100+
<div
101+
key={dIndex}
102+
onMouseEnter={(e) => handleMouseEnter(e, day)}
103+
onMouseLeave={handleMouseLeave}
104+
className={`rounded-sm cursor-pointer transition-all duration-150 hover:brightness-125 hover:scale-125 ${getIntensityColor(day.intensity)}`}
105+
style={{ width: CELL, height: CELL }}
106+
/>
107+
))}
108+
</div>
109+
))}
110+
</div>
108111
</div>
109112
</div>
110-
</div>
113+
) : (
114+
<div className="h-[120px] flex items-center justify-center rounded-lg border border-dashed border-black/10 dark:border-[rgba(255,255,255,0.08)] text-sm text-[#A1A1AA]">
115+
No recent activity to display
116+
</div>
117+
)}
111118
</motion.div>
112119

113120
{/* Tooltip rendered at viewport level — unaffected by scale/overflow */}

0 commit comments

Comments
 (0)