Skip to content

Commit ddc719b

Browse files
committed
feat(trends): add support for historical contribution views
1 parent a4dcf09 commit ddc719b

9 files changed

Lines changed: 773 additions & 36 deletions

File tree

app/(root)/dashboard/[username]/page.test.tsx

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ vi.mock('@/components/dashboard/Heatmap', () => ({
5656
),
5757
}));
5858

59+
vi.mock('@/components/dashboard/HistoricalTrendView', () => ({
60+
default: ({ period, activity }: { period: { label: string }; activity: unknown[] }) => (
61+
<div data-testid="historical-trend-view" data-prop={JSON.stringify(activity)}>
62+
{period.label}
63+
</div>
64+
),
65+
}));
66+
5967
vi.mock('@/components/dashboard/AIInsights', () => ({
6068
default: () => <div data-testid="ai-insights">AIInsights</div>,
6169
}));
@@ -147,9 +155,15 @@ describe('DashboardPage', () => {
147155

148156
render(PageContent);
149157

150-
expect(getFullDashboardData).toHaveBeenCalledWith('octocat', {
151-
bypassCache: false,
152-
});
158+
expect(getFullDashboardData).toHaveBeenCalledWith(
159+
'octocat',
160+
expect.objectContaining({
161+
bypassCache: false,
162+
from: expect.any(String),
163+
to: expect.any(String),
164+
rangeLabel: 'Last 12 months',
165+
})
166+
);
153167

154168
const generateLink = screen.getByText('Generate Your Own').closest('a');
155169
expect(generateLink).toBeDefined();
@@ -158,7 +172,7 @@ describe('DashboardPage', () => {
158172
expect(screen.getByTestId('activity-landscape')).toBeDefined();
159173
expect(screen.getByTestId('language-chart')).toBeDefined();
160174
expect(screen.getByTestId('commit-clock')).toBeDefined();
161-
expect(screen.getByTestId('heatmap')).toBeDefined();
175+
expect(screen.getByTestId('historical-trend-view')).toBeDefined();
162176
expect(screen.getByTestId('ai-insights')).toBeDefined();
163177
expect(screen.getByTestId('achievements')).toBeDefined();
164178
expect(screen.getAllByTestId('stats-card')).toHaveLength(3);
@@ -175,21 +189,46 @@ describe('DashboardPage', () => {
175189

176190
render(PageContent);
177191

178-
expect(getFullDashboardData).toHaveBeenCalledWith('octocat', {
179-
bypassCache: true,
192+
expect(getFullDashboardData).toHaveBeenCalledWith(
193+
'octocat',
194+
expect.objectContaining({
195+
bypassCache: true,
196+
from: expect.any(String),
197+
to: expect.any(String),
198+
rangeLabel: 'Last 12 months',
199+
})
200+
);
201+
});
202+
203+
it('passes a calendar-year query through to getFullDashboardData', async () => {
204+
const PageContent = await DashboardPage({
205+
params: Promise.resolve({ username: 'octocat' }),
206+
searchParams: Promise.resolve({ year: '2024' }),
180207
});
208+
209+
render(PageContent);
210+
211+
expect(getFullDashboardData).toHaveBeenCalledWith(
212+
'octocat',
213+
expect.objectContaining({
214+
bypassCache: false,
215+
from: '2024-01-01T00:00:00.000Z',
216+
to: '2024-12-31T23:59:59.999Z',
217+
rangeLabel: '2024',
218+
})
219+
);
181220
});
182221

183-
it('passes the correct activity data to Heatmap', async () => {
222+
it('passes the correct activity data to the historical trend view', async () => {
184223
const PageContent = await DashboardPage({
185224
params: Promise.resolve({ username: 'octocat' }),
186225
searchParams: Promise.resolve({}),
187226
});
188227

189228
render(PageContent);
190229

191-
const heatmap = screen.getByTestId('heatmap');
192-
expect(JSON.parse(heatmap.getAttribute('data-prop') ?? '[]')).toEqual(mockData.activity);
230+
const trendView = screen.getByTestId('historical-trend-view');
231+
expect(JSON.parse(trendView.getAttribute('data-prop') ?? '[]')).toEqual(mockData.activity);
193232
});
194233

195234
it('calls notFound when dashboard data fetch throws an error', async () => {

app/(root)/dashboard/[username]/page.tsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Metadata } from 'next';
44
import DashboardClient from '@/components/dashboard/DashboardClient';
55
import { getFullDashboardData, fetchUserProfile } from '@/lib/github';
66
import { notFound, redirect } from 'next/navigation';
7+
import { resolveDashboardPeriod } from '@/utils/dashboardPeriod';
78

89
export const revalidate = 3600; // Cache for 1 hour
910

@@ -60,16 +61,33 @@ export default async function DashboardPage({
6061
searchParams,
6162
}: {
6263
params: Promise<{ username: string }>;
63-
searchParams: Promise<{ refresh?: string }>;
64+
searchParams: Promise<{
65+
refresh?: string;
66+
year?: string;
67+
month?: string;
68+
from?: string;
69+
to?: string;
70+
}>;
6471
}) {
6572
const { username } = await params;
66-
const refreshParams = await searchParams;
67-
const bypassCache = refreshParams?.refresh === 'true';
73+
const resolvedSearchParams = await searchParams;
74+
const bypassCache = resolvedSearchParams?.refresh === 'true';
75+
const period = resolveDashboardPeriod({
76+
year: resolvedSearchParams?.year,
77+
month: resolvedSearchParams?.month,
78+
from: resolvedSearchParams?.from,
79+
to: resolvedSearchParams?.to,
80+
});
6881

6982
let data;
7083

7184
try {
72-
data = await getFullDashboardData(username, { bypassCache });
85+
data = await getFullDashboardData(username, {
86+
bypassCache,
87+
from: period.from,
88+
to: period.to,
89+
rangeLabel: period.label,
90+
});
7391
} catch (error) {
7492
if (error instanceof Error && error.message.includes('not found')) {
7593
// Smart Redirect: If the GraphQL "user" query fails, check if it's actually an Organization
@@ -87,5 +105,5 @@ export default async function DashboardPage({
87105
throw error;
88106
}
89107

90-
return <DashboardClient initialData={data} username={username} />;
108+
return <DashboardClient initialData={data} username={username} period={period} />;
91109
}

components/dashboard/DashboardClient.test.tsx

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ vi.mock('./Heatmap', () => ({
8888
default: () => <div data-testid="heatmap" />,
8989
}));
9090

91+
vi.mock('./HistoricalTrendView', () => ({
92+
default: () => <div data-testid="historical-trend-view" />,
93+
}));
94+
9195
vi.mock('./AIInsights', () => ({
9296
default: () => <div data-testid="ai-insights" />,
9397
}));
@@ -190,24 +194,36 @@ const secondDataWithLowerStreak = {
190194
},
191195
};
192196

197+
const mockPeriod = {
198+
kind: 'year' as const,
199+
label: '2026',
200+
from: '2026-01-01T00:00:00.000Z',
201+
to: '2026-12-31T23:59:59.999Z',
202+
year: '2026',
203+
};
204+
193205
describe('DashboardClient', () => {
194206
beforeEach(() => {
195207
vi.restoreAllMocks();
196208
});
197209

198210
it('renders standard single profile view by default', () => {
199-
render(<DashboardClient initialData={mockInitialData} username="Shivangi1515" />);
211+
render(
212+
<DashboardClient initialData={mockInitialData} username="Shivangi1515" period={mockPeriod} />
213+
);
200214

201215
expect(screen.getByText('Shivangi')).toBeDefined();
202216
expect(screen.getByTestId('profile-card')).toBeDefined();
203217
expect(screen.getByTestId('achievements')).toBeDefined();
204218
expect(screen.getByTestId('activity-landscape')).toBeDefined();
205219
expect(screen.getByTestId('language-chart')).toBeDefined();
206-
expect(screen.getByTestId('heatmap')).toBeDefined();
220+
expect(screen.getByTestId('historical-trend-view')).toBeDefined();
207221
});
208222

209223
it('opens and closes the compare profile modal', async () => {
210-
render(<DashboardClient initialData={mockInitialData} username="Shivangi1515" />);
224+
render(
225+
<DashboardClient initialData={mockInitialData} username="Shivangi1515" period={mockPeriod} />
226+
);
211227

212228
const compareBtn = screen.getByText('Compare Profile');
213229
expect(compareBtn).toBeDefined();
@@ -232,7 +248,9 @@ describe('DashboardClient', () => {
232248
);
233249
vi.stubGlobal('fetch', mockFetch);
234250

235-
render(<DashboardClient initialData={mockInitialData} username="Shivangi1515" />);
251+
render(
252+
<DashboardClient initialData={mockInitialData} username="Shivangi1515" period={mockPeriod} />
253+
);
236254

237255
const compareBtn = screen.getByText('Compare Profile');
238256
fireEvent.click(compareBtn);
@@ -265,7 +283,9 @@ describe('DashboardClient', () => {
265283
);
266284
vi.stubGlobal('fetch', mockFetch);
267285

268-
render(<DashboardClient initialData={mockInitialData} username="Shivangi1515" />);
286+
render(
287+
<DashboardClient initialData={mockInitialData} username="Shivangi1515" period={mockPeriod} />
288+
);
269289

270290
// 1. Enter compare mode
271291
const compareBtn = screen.getByText('Compare Profile');
@@ -300,7 +320,9 @@ describe('DashboardClient', () => {
300320
});
301321

302322
it('generate your own button points to root /', () => {
303-
render(<DashboardClient initialData={mockInitialData} username="Shivangi1515" />);
323+
render(
324+
<DashboardClient initialData={mockInitialData} username="Shivangi1515" period={mockPeriod} />
325+
);
304326

305327
const generateLink = screen.getByRole('link', { name: /generate your own/i });
306328
expect(generateLink.getAttribute('href')).toBe('/');
@@ -309,7 +331,9 @@ describe('DashboardClient', () => {
309331
// ISSUE OBJECTIVE: Verify error is shown when comparing with same username
310332
// =========================================================================
311333
it('shows an error when comparing with the same username (case-insensitive)', async () => {
312-
render(<DashboardClient initialData={mockInitialData} username="Shivangi1515" />);
334+
render(
335+
<DashboardClient initialData={mockInitialData} username="Shivangi1515" period={mockPeriod} />
336+
);
313337

314338
// 1. Open modal
315339
const compareBtn = screen.getByText('Compare Profile');
@@ -334,7 +358,9 @@ describe('DashboardClient', () => {
334358
// ISSUE OBJECTIVE #1063: Verify compare modal input can be cleared
335359
// =========================================================================
336360
it('verify compare modal input can be cleared', async () => {
337-
render(<DashboardClient initialData={mockInitialData} username="Shivangi1515" />);
361+
render(
362+
<DashboardClient initialData={mockInitialData} username="Shivangi1515" period={mockPeriod} />
363+
);
338364

339365
// 1. Open modal
340366
const compareBtn = screen.getByText('Compare Profile');
@@ -366,7 +392,9 @@ describe('DashboardClient', () => {
366392

367393
vi.stubGlobal('fetch', mockFetch);
368394

369-
render(<DashboardClient initialData={mockInitialData} username="Shivangi1515" />);
395+
render(
396+
<DashboardClient initialData={mockInitialData} username="Shivangi1515" period={mockPeriod} />
397+
);
370398

371399
fireEvent.click(screen.getByText('Compare Profile'));
372400

@@ -394,7 +422,13 @@ it('shows Most Consistent badge for profile with higher peak streak in compare m
394422

395423
vi.stubGlobal('fetch', mockFetch);
396424

397-
render(<DashboardClient initialData={initialDataWithHigherStreak} username="Shivangi1515" />);
425+
render(
426+
<DashboardClient
427+
initialData={initialDataWithHigherStreak}
428+
username="Shivangi1515"
429+
period={mockPeriod}
430+
/>
431+
);
398432

399433
fireEvent.click(screen.getByText('Compare Profile'));
400434

components/dashboard/DashboardClient.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ import ActivityLandscape from './ActivityLandscape';
1515
import LanguageChart from './LanguageChart';
1616
import CommitClock from './CommitClock';
1717
import Heatmap from './Heatmap';
18+
import HistoricalTrendView from './HistoricalTrendView';
1819
import AIInsights from './AIInsights';
1920
import StatsCard from './StatsCard';
2021
import RepositoryGraph from './RepositoryGraph';
2122
import ComparisonStatsCard from './ComparisonStatsCard';
2223
import RadarChart from './RadarChart';
2324
import GrowthTrendChart from './GrowthTrendChart';
2425
import ProfileOptimizerModal from './ProfileOptimizerModal';
26+
import type { DashboardPeriod } from '@/utils/dashboardPeriod';
2527

2628
// Define the dashboard data structure
2729
interface DashboardData {
@@ -75,6 +77,7 @@ interface DashboardData {
7577
interface DashboardClientProps {
7678
initialData: DashboardData;
7779
username: string;
80+
period: DashboardPeriod;
7881
}
7982

8083
export interface ProfileMetrics {
@@ -318,7 +321,7 @@ function getPersonalityTags(
318321
// DashboardClient Component
319322
// ------------------------------------------------------------
320323

321-
export default function DashboardClient({ initialData, username }: DashboardClientProps) {
324+
export default function DashboardClient({ initialData, username, period }: DashboardClientProps) {
322325
const [secondUserData, setSecondUserData] = useState<DashboardData | null>(null);
323326
const [isCompareMode, setIsCompareMode] = useState(false);
324327
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -569,7 +572,11 @@ export default function DashboardClient({ initialData, username }: DashboardClie
569572
</section>
570573

571574
<section>
572-
<Heatmap data={initialData.activity} />
575+
<HistoricalTrendView
576+
activity={initialData.activity}
577+
username={username}
578+
period={period}
579+
/>
573580
</section>
574581
</div>
575582

@@ -595,7 +602,7 @@ export default function DashboardClient({ initialData, username }: DashboardClie
595602
<StatsCard
596603
title="Contributions"
597604
value={initialData.stats.totalContributions.toString()}
598-
description="Last Year"
605+
description={period.label}
599606
icon="GitCommit"
600607
/>
601608
</div>

components/dashboard/Heatmap.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,19 @@ interface TooltipState {
2525
y: number;
2626
}
2727

28-
export default function Heatmap({ data }: { data: ActivityData[] }) {
28+
interface HeatmapProps {
29+
data: ActivityData[];
30+
title?: string;
31+
subtitle?: string;
32+
emptyMessage?: string;
33+
}
34+
35+
export default function Heatmap({
36+
data,
37+
title = 'Contribution Heatmap',
38+
subtitle = 'Last 365 days',
39+
emptyMessage = 'No recent activity to display',
40+
}: HeatmapProps) {
2941
const containerRef = useRef<HTMLDivElement>(null);
3042
const [scale, setScale] = useState(1);
3143
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
@@ -83,12 +95,12 @@ export default function Heatmap({ data }: { data: ActivityData[] }) {
8395
>
8496
{/* Header */}
8597
<h3 className="my-1 text-sm font-semibold tracking-tight text-gray-900 dark:text-white">
86-
Contribution Heatmap
98+
{title}
8799
</h3>
88100

89101
<div className="mb-4 flex items-end justify-between">
90102
<div>
91-
<p className="mt-0.5 text-xs text-[#A1A1AA]">Last 365 days</p>
103+
<p className="mt-0.5 text-xs text-[#A1A1AA]">{subtitle}</p>
92104
</div>
93105

94106
<div className="flex items-center gap-2 text-xs text-[#A1A1AA]">
@@ -147,7 +159,7 @@ export default function Heatmap({ data }: { data: ActivityData[] }) {
147159
</div>
148160
) : (
149161
<div className="flex h-[120px] items-center justify-center rounded-lg border border-dashed border-black/10 text-sm text-[#A1A1AA] dark:border-[rgba(255,255,255,0.08)]">
150-
No recent activity to display
162+
{emptyMessage}
151163
</div>
152164
)}
153165
</motion.div>

0 commit comments

Comments
 (0)