Skip to content

Commit ec23bda

Browse files
committed
feat(pulse): Add Heartbeat Sparkline SVG generator and customize integration
1 parent 0e9b7a6 commit ec23bda

12 files changed

Lines changed: 781 additions & 288 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)