Skip to content

Commit a69a12d

Browse files
authored
feat: redesign customize page to 3-column layout and refine sparkline animation (JhaSourav07#1723)
## Description This PR introduces two major enhancements to the CommitPulse customization studio and the heartbeat sparkline: 1. **Sparkline Upgrades (30 Days)**: - Adjusted layout proportions to provide extra padding between the graph and the stats labels. - Refined the sparkline's drawing animation: By leveraging `pathLength="1"`, the SVG line draws in smoothly over 2 seconds, greatly enhancing the premium visual feel. 2. **3-Column Customization Studio**: - Addressed UX issues where advanced settings were hidden far down the page on desktop. - Extracted the advanced settings into a dedicated `AdvancedSettingsPanel` component. - Refactored `page.tsx` to utilize a sleek `xl:grid-cols-[340px_1fr_340px]` 3-column layout. Now, basic controls sit on the left, the live preview stays prominently in the center, and the advanced settings flank the right side—keeping everything visible at once! Fixes JhaSourav07#1339 ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [ ] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview https://github.com/user-attachments/assets/b351dd05-79b0-43e1-877f-3eceb9533e0d ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [x] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have started the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
2 parents 6cff330 + ec23bda commit a69a12d

12 files changed

Lines changed: 751 additions & 277 deletions

app/api/streak/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
generateMonthlySVG,
1111
generateVersusSVG,
1212
generateHeatmapSVG,
13+
generatePulseSVG,
1314
} from '@/lib/svg/generator';
1415
import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '@/utils/time';
1516
import type { BadgeParams } from '@/types';
@@ -201,6 +202,11 @@ export async function GET(request: Request) {
201202
} else if (view === 'heatmap') {
202203
const stats = calculateStreak(calendar, timezone, undefined, grace);
203204
svg = generateHeatmapSVG(stats, params, calendar);
205+
} else if (view === 'pulse') {
206+
// We still use calculateStreak here to efficiently parse totalContributions for the stat display,
207+
// even though the sparkline generator will extract its own daily 30-day timeline below.
208+
const stats = calculateStreak(calendar, timezone, undefined, grace);
209+
svg = generatePulseSVG(stats, params, calendar);
204210
} else if (versus && versusCalendar) {
205211
const stats1 = calculateStreak(calendar, timezone, undefined, grace);
206212
const stats2 = calculateStreak(versusCalendar, timezone, undefined, grace);
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import type { ComponentProps } from 'react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import { AdvancedSettingsPanel } from './AdvancedSettingsPanel';
5+
import type { DeltaFormat, Language, Timezone, ViewMode } from '../types';
6+
7+
const defaultProps = {
8+
hideTitle: false,
9+
hideBackground: false,
10+
hideStats: false,
11+
viewMode: 'default' as ViewMode,
12+
deltaFormat: 'percent' as DeltaFormat,
13+
badgeWidth: '' as const,
14+
badgeHeight: '' as const,
15+
grace: 1,
16+
language: 'en' as Language,
17+
timezone: 'UTC' as Timezone,
18+
onHideTitleChange: vi.fn(),
19+
onHideBackgroundChange: vi.fn(),
20+
onHideStatsChange: vi.fn(),
21+
onViewModeChange: vi.fn(),
22+
onDeltaFormatChange: vi.fn(),
23+
onBadgeWidthChange: vi.fn(),
24+
onBadgeHeightChange: vi.fn(),
25+
onGraceChange: vi.fn(),
26+
onLanguageChange: vi.fn(),
27+
onTimezoneChange: vi.fn(),
28+
} satisfies ComponentProps<typeof AdvancedSettingsPanel>;
29+
30+
describe('AdvancedSettingsPanel timezone control', () => {
31+
it('renders UTC as the default timezone option', () => {
32+
render(<AdvancedSettingsPanel {...defaultProps} />);
33+
34+
expect((screen.getByLabelText('Timezone') as HTMLSelectElement).value).toBe('UTC');
35+
});
36+
37+
it('calls onTimezoneChange with the selected IANA timezone', () => {
38+
const onTimezoneChange = vi.fn();
39+
render(<AdvancedSettingsPanel {...defaultProps} onTimezoneChange={onTimezoneChange} />);
40+
41+
fireEvent.change(screen.getByLabelText('Timezone'), {
42+
target: { value: 'Asia/Kolkata' },
43+
});
44+
45+
expect(onTimezoneChange).toHaveBeenCalledWith('Asia/Kolkata');
46+
});
47+
});
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import type { ReactElement } from 'react';
2+
import {
3+
LANGUAGES,
4+
TIMEZONES,
5+
VIEW_MODES,
6+
DELTA_FORMATS,
7+
type ViewMode,
8+
type DeltaFormat,
9+
type Language,
10+
type Timezone,
11+
} from '../types';
12+
import { SectionLabel } from './SectionLabel';
13+
import { StyledSelect } from './ThemeSelector';
14+
15+
function ControlRow({
16+
label,
17+
children,
18+
}: {
19+
label: string;
20+
children: React.ReactNode;
21+
}): ReactElement {
22+
return (
23+
<div className="flex flex-col gap-1.5">
24+
<SectionLabel>{label}</SectionLabel>
25+
{children}
26+
</div>
27+
);
28+
}
29+
30+
export function AdvancedSettingsPanel({
31+
hideTitle,
32+
hideBackground,
33+
hideStats,
34+
viewMode,
35+
deltaFormat,
36+
badgeWidth,
37+
badgeHeight,
38+
grace,
39+
language,
40+
timezone,
41+
onHideTitleChange,
42+
onHideBackgroundChange,
43+
onHideStatsChange,
44+
onViewModeChange,
45+
onDeltaFormatChange,
46+
onBadgeWidthChange,
47+
onBadgeHeightChange,
48+
onGraceChange,
49+
onLanguageChange,
50+
onTimezoneChange,
51+
}: {
52+
hideTitle: boolean;
53+
hideBackground: boolean;
54+
hideStats: boolean;
55+
viewMode: ViewMode;
56+
deltaFormat: DeltaFormat;
57+
badgeWidth: number | '';
58+
badgeHeight: number | '';
59+
grace: number;
60+
language: Language;
61+
timezone: Timezone;
62+
onHideTitleChange: (value: boolean) => void;
63+
onHideBackgroundChange: (value: boolean) => void;
64+
onHideStatsChange: (value: boolean) => void;
65+
onViewModeChange: (value: ViewMode) => void;
66+
onDeltaFormatChange: (value: DeltaFormat) => void;
67+
onBadgeWidthChange: (value: number | '') => void;
68+
onBadgeHeightChange: (value: number | '') => void;
69+
onGraceChange: (value: number) => void;
70+
onLanguageChange: (value: Language) => void;
71+
onTimezoneChange: (value: Timezone) => void;
72+
}): ReactElement {
73+
return (
74+
<div>
75+
<p className="text-xs font-bold uppercase tracking-[0.22em] text-emerald-600 dark:text-emerald-400 mb-4">
76+
Advanced Settings
77+
</p>
78+
79+
<div className="flex flex-col gap-5">
80+
{/* Visibility Toggles */}
81+
<ControlRow label="Visibility Options">
82+
<div className="flex flex-col gap-2">
83+
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-white/70">
84+
<input
85+
type="checkbox"
86+
checked={hideTitle}
87+
onChange={(e) => onHideTitleChange(e.target.checked)}
88+
className="rounded border-black/20 dark:border-white/20 bg-transparent text-emerald-500 focus:ring-emerald-500/50"
89+
/>
90+
Hide Title
91+
</label>
92+
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-white/70">
93+
<input
94+
type="checkbox"
95+
checked={hideBackground}
96+
onChange={(e) => onHideBackgroundChange(e.target.checked)}
97+
className="rounded border-black/20 dark:border-white/20 bg-transparent text-emerald-500 focus:ring-emerald-500/50"
98+
/>
99+
Hide Background
100+
</label>
101+
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-white/70">
102+
<input
103+
type="checkbox"
104+
checked={hideStats}
105+
onChange={(e) => onHideStatsChange(e.target.checked)}
106+
className="rounded border-black/20 dark:border-white/20 bg-transparent text-emerald-500 focus:ring-emerald-500/50"
107+
/>
108+
Hide Stats
109+
</label>
110+
</div>
111+
</ControlRow>
112+
113+
<div className="h-px bg-black/5 dark:bg-white/5" />
114+
115+
{/* Layout Options */}
116+
<ControlRow label="View Layout">
117+
<div className="relative">
118+
<StyledSelect
119+
id="view-select"
120+
value={viewMode}
121+
onChange={(v) => onViewModeChange(v as ViewMode)}
122+
>
123+
{VIEW_MODES.map((mode) => (
124+
<option key={mode.value} value={mode.value}>
125+
{mode.label}
126+
</option>
127+
))}
128+
</StyledSelect>
129+
</div>
130+
</ControlRow>
131+
132+
<ControlRow label="Delta Format">
133+
<div className="relative">
134+
<StyledSelect
135+
id="delta-select"
136+
value={deltaFormat}
137+
onChange={(v) => onDeltaFormatChange(v as DeltaFormat)}
138+
>
139+
{DELTA_FORMATS.map((format) => (
140+
<option key={format.value} value={format.value}>
141+
{format.label}
142+
</option>
143+
))}
144+
</StyledSelect>
145+
</div>
146+
</ControlRow>
147+
148+
<div className="h-px bg-black/5 dark:bg-white/5" />
149+
150+
{/* Dimensions */}
151+
<div className="grid grid-cols-2 gap-4">
152+
<ControlRow label="Width">
153+
<input
154+
type="number"
155+
min="100"
156+
max="1200"
157+
placeholder="Auto"
158+
value={badgeWidth}
159+
onChange={(e) => {
160+
const val = e.currentTarget.valueAsNumber;
161+
onBadgeWidthChange(Number.isNaN(val) ? '' : val);
162+
}}
163+
className="w-full bg-white/60 backdrop-blur-md border border-black/10 dark:bg-black/40 dark:border-white/10 rounded-xl px-3 py-2 text-sm font-mono text-black dark:text-emerald-300 placeholder:text-gray-400 dark:placeholder:text-white/20 outline-none focus:border-emerald-500/50 transition-colors"
164+
/>
165+
</ControlRow>
166+
<ControlRow label="Height">
167+
<input
168+
type="number"
169+
min="80"
170+
max="800"
171+
placeholder="Auto"
172+
value={badgeHeight}
173+
onChange={(e) => {
174+
const val = e.currentTarget.valueAsNumber;
175+
onBadgeHeightChange(Number.isNaN(val) ? '' : val);
176+
}}
177+
className="w-full bg-white/60 backdrop-blur-md border border-black/10 dark:bg-black/40 dark:border-white/10 rounded-xl px-3 py-2 text-sm font-mono text-black dark:text-emerald-300 placeholder:text-gray-400 dark:placeholder:text-white/20 outline-none focus:border-emerald-500/50 transition-colors"
178+
/>
179+
</ControlRow>
180+
</div>
181+
182+
<div className="h-px bg-black/5 dark:bg-white/5" />
183+
184+
{/* Grace and Localization */}
185+
<ControlRow label="Grace Days">
186+
<div className="relative flex items-center">
187+
<div className="absolute inset-x-0 h-1 rounded-full bg-gray-300 dark:bg-white/6" />
188+
<input
189+
type="range"
190+
min="0"
191+
max="7"
192+
step="1"
193+
value={grace}
194+
onChange={(e) => onGraceChange(Number(e.target.value))}
195+
className="w-full relative bg-transparent appearance-none outline-none slider"
196+
/>
197+
</div>
198+
<div className="flex justify-between text-sm text-gray-500 dark:text-white/20">
199+
<span>0</span>
200+
<span className="text-emerald-600 dark:text-emerald-300/60 font-mono text-[11px]">
201+
{grace}
202+
</span>
203+
<span>7</span>
204+
</div>
205+
</ControlRow>
206+
207+
<ControlRow label="Language">
208+
<div className="relative">
209+
<StyledSelect
210+
id="lang-select"
211+
value={language}
212+
onChange={(v) => onLanguageChange(v as Language)}
213+
>
214+
{LANGUAGES.map((lang) => (
215+
<option key={lang.value} value={lang.value}>
216+
{lang.label}
217+
</option>
218+
))}
219+
</StyledSelect>
220+
</div>
221+
</ControlRow>
222+
223+
<ControlRow label="Timezone">
224+
<div className="relative">
225+
<StyledSelect
226+
id="timezone-select"
227+
ariaLabel="Timezone"
228+
value={timezone}
229+
onChange={(v) => onTimezoneChange(v as Timezone)}
230+
>
231+
{TIMEZONES.map((tz) => (
232+
<option key={tz.value} value={tz.value}>
233+
{tz.label}
234+
</option>
235+
))}
236+
</StyledSelect>
237+
</div>
238+
</ControlRow>
239+
</div>
240+
</div>
241+
);
242+
}

0 commit comments

Comments
 (0)