Skip to content

Commit c6f9083

Browse files
Merge branch 'main' into docs/cache-getorset-comments
2 parents e58b229 + b04ef58 commit c6f9083

8 files changed

Lines changed: 691 additions & 33 deletions
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { render } from '@testing-library/react';
2+
import { describe, expect, expectTypeOf, it, vi } from 'vitest';
3+
import ScrollRestoration from './ScrollRestoration';
4+
5+
vi.mock('next/navigation', () => ({
6+
usePathname: () => '/contributors',
7+
}));
8+
9+
type ScrollRestorationComponent = typeof ScrollRestoration;
10+
type ScrollRestorationProps = Parameters<ScrollRestorationComponent>;
11+
12+
function validateScrollStorageKey(key: string) {
13+
return /^scroll-position-\/.+/.test(key);
14+
}
15+
16+
describe('ScrollRestoration type compiler validation', () => {
17+
it('exports a callable React component with no required props', () => {
18+
expectTypeOf(ScrollRestoration).toBeFunction();
19+
expectTypeOf<ScrollRestorationProps>().toEqualTypeOf<[]>();
20+
});
21+
22+
it('renders without visible DOM output', () => {
23+
const { container } = render(<ScrollRestoration />);
24+
25+
expect(container.textContent).toBe('');
26+
expect(container.firstElementChild).toBeNull();
27+
});
28+
29+
it('blocks invalid prop parameters at compile time', () => {
30+
expectTypeOf<ScrollRestorationProps>().not.toEqualTypeOf<[{ pathname: string }]>();
31+
});
32+
33+
it('accepts optional pathname-like values in schema helper checks', () => {
34+
type OptionalPathname = string | undefined;
35+
36+
const optionalPathname: OptionalPathname = '/contributors';
37+
38+
expectTypeOf<OptionalPathname>().toEqualTypeOf<string | undefined>();
39+
40+
expect(validateScrollStorageKey(`scroll-position-${optionalPathname}`)).toBe(true);
41+
});
42+
43+
it('returns strict validation reports for scroll storage key constraints', () => {
44+
expect(validateScrollStorageKey('scroll-position-/contributors')).toBe(true);
45+
expect(validateScrollStorageKey('scroll-position-')).toBe(false);
46+
expect(validateScrollStorageKey('contributors')).toBe(false);
47+
expect(validateScrollStorageKey('')).toBe(false);
48+
});
49+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, expect, it } from 'vitest';
3+
import Loading from './loading';
4+
5+
function hasClasses(element: Element | null, classes: string[]) {
6+
expect(element).not.toBeNull();
7+
8+
for (const className of classes) {
9+
expect(element!.classList.contains(className)).toBe(true);
10+
}
11+
}
12+
13+
describe('Contributors loading accessibility', () => {
14+
it('exposes the loading state through a screen-reader status region', () => {
15+
render(<Loading />);
16+
17+
const status = screen.getByRole('status');
18+
19+
expect(status.getAttribute('aria-live')).toBe('polite');
20+
expect(status.textContent).toContain('Loading the collective...');
21+
expect(status.textContent).toContain('Fetching contributor data from GitHub');
22+
});
23+
24+
it('keeps descriptive loading text available to assistive technologies', () => {
25+
render(<Loading />);
26+
27+
expect(screen.getByText('Loading the collective...')).toBeTruthy();
28+
expect(screen.getByText('Fetching contributor data from GitHub')).toBeTruthy();
29+
});
30+
31+
it('does not expose decorative spinner elements as interactive controls', () => {
32+
render(<Loading />);
33+
34+
expect(screen.queryByRole('button')).toBeNull();
35+
expect(screen.queryByRole('link')).toBeNull();
36+
expect(screen.queryByRole('textbox')).toBeNull();
37+
});
38+
39+
it('preserves visual focus-friendly layout without hidden foreground text', () => {
40+
render(<Loading />);
41+
42+
const status = screen.getByRole('status');
43+
const page = status.parentElement;
44+
45+
hasClasses(page, [
46+
'flex',
47+
'min-h-screen',
48+
'items-center',
49+
'justify-center',
50+
'bg-[#050505]',
51+
'text-white',
52+
]);
53+
54+
expect(status.classList.contains('sr-only')).toBe(false);
55+
expect(status.classList.contains('hidden')).toBe(false);
56+
expect(status.classList.contains('text-transparent')).toBe(false);
57+
});
58+
59+
it('keeps DOM reading order logical for keyboard and screen-reader traversal', () => {
60+
render(<Loading />);
61+
62+
const status = screen.getByRole('status');
63+
const children = Array.from(status.children);
64+
65+
expect(children.length).toBeGreaterThanOrEqual(3);
66+
expect(children[0].tagName.toLowerCase()).toBe('div');
67+
expect(children[1].textContent).toBe('Loading the collective...');
68+
expect(children[2].textContent).toBe('Fetching contributor data from GitHub');
69+
});
70+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import ShareButtons from './ShareButtons';
5+
6+
describe('ShareButtons accessibility behavior', () => {
7+
const url = 'https://example.com';
8+
const title = 'Test Title';
9+
10+
const renderShareButtons = () => render(<ShareButtons url={url} title={title} />);
11+
12+
it('provides an accessible label for the LinkedIn share link', () => {
13+
renderShareButtons();
14+
15+
const link = screen.getByRole('link', {
16+
name: 'Share on LinkedIn (opens in a new tab)',
17+
});
18+
19+
expect(link).toBeTruthy();
20+
});
21+
22+
it('provides an accessible label for the X/Twitter share link', () => {
23+
renderShareButtons();
24+
25+
const link = screen.getByRole('link', {
26+
name: 'Share on X / Twitter (opens in a new tab)',
27+
});
28+
29+
expect(link).toBeTruthy();
30+
});
31+
32+
it('marks social media icons as decorative', () => {
33+
const { container } = renderShareButtons();
34+
35+
const icons = container.querySelectorAll('svg[aria-hidden="true"]');
36+
37+
expect(icons).toHaveLength(2);
38+
});
39+
40+
it('opens LinkedIn share link in a new tab securely', () => {
41+
renderShareButtons();
42+
43+
const link = screen.getByRole('link', {
44+
name: 'Share on LinkedIn (opens in a new tab)',
45+
});
46+
47+
expect(link.getAttribute('target')).toBe('_blank');
48+
49+
const rel = link.getAttribute('rel') ?? '';
50+
51+
expect(rel).toContain('noopener');
52+
expect(rel).toContain('noreferrer');
53+
});
54+
55+
it('opens X/Twitter share link in a new tab securely', () => {
56+
renderShareButtons();
57+
58+
const link = screen.getByRole('link', {
59+
name: 'Share on X / Twitter (opens in a new tab)',
60+
});
61+
62+
expect(link.getAttribute('target')).toBe('_blank');
63+
64+
const rel = link.getAttribute('rel') ?? '';
65+
66+
expect(rel).toContain('noopener');
67+
expect(rel).toContain('noreferrer');
68+
});
69+
});

components/dashboard/DashboardClient.tsx

Lines changed: 23 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,33 @@ interface DashboardClientProps {
8686
export interface ProfileMetrics {
8787
currentStreak: number;
8888
commitClock: { day: string; commits: number }[]; // e.g., Sun-Sat daily totals
89-
hourlyData?: { hour: number; commits: number }[]; // Optional: 0-23 hour distribution
9089
}
9190

9291
export interface CoderProfile {
9392
peakHourStart: number;
9493
peakHourEnd: number;
95-
profileName: 'Night Owl 🌙' | 'Early Builder ☀' | 'Weekend Warrior 🚀' | 'Consistent Runner 🏃‍♂️';
94+
profileName: 'Early Builder ☀' | 'Weekend Warrior 🚀' | 'Consistent Runner 🏃‍♂️';
9695
hourlyDistribution: number[];
9796
activeWeekdays: string[];
9897
}
9998

99+
/**
100+
* Generates a coder profile based on available metrics.
101+
*
102+
* NOTE: Night Owl classification via hourlyData is NOT IMPLEMENTED.
103+
* GitHub's REST API only provides daily contribution granularity. Fetching hourly data
104+
* would require querying individual commits across all repositories, which is:
105+
* - Prohibitively expensive in latency (100s-1000s of requests per user)
106+
* - Infeasible within serverless function timeout constraints (~10 seconds)
107+
* - Not required for daily activity visualization use cases
108+
*
109+
* Instead, we classify developers into 3 profile types:
110+
* - Consistent Runner: High daily commit frequency (streak >= 10)
111+
* - Weekend Warrior: Most commits occur on weekends (>35% of commits)
112+
* - Early Builder: Default for other patterns
113+
*/
100114
export function generateCoderProfile(metrics: ProfileMetrics): CoderProfile {
101-
const { currentStreak, commitClock, hourlyData } = metrics;
115+
const { currentStreak, commitClock } = metrics;
102116

103117
let profileName: CoderProfile['profileName'] = 'Early Builder ☀';
104118

@@ -112,45 +126,21 @@ export function generateCoderProfile(metrics: ProfileMetrics): CoderProfile {
112126
}
113127
}
114128

115-
// 2. Analyze Hourly Data for Night Owl vs Early Builder
116-
let isNightOwl = false;
117-
if (hourlyData && hourlyData.length > 0) {
118-
const nightCommits = hourlyData
119-
.filter((d) => d.hour >= 22 || d.hour <= 3)
120-
.reduce((sum, d) => sum + d.commits, 0);
121-
122-
const morningCommits = hourlyData
123-
.filter((d) => d.hour >= 5 && d.hour <= 10)
124-
.reduce((sum, d) => sum + d.commits, 0);
125-
126-
isNightOwl = nightCommits > morningCommits;
127-
}
128-
129-
// 3. Determine Final Profile Type
129+
// 2. Determine Final Profile Type
130130
if (currentStreak >= 10) {
131131
profileName = 'Consistent Runner 🏃‍♂️';
132132
} else if (isWeekendWarrior) {
133133
profileName = 'Weekend Warrior 🚀';
134-
} else if (isNightOwl) {
135-
profileName = 'Night Owl 🌙';
136134
}
137135

138-
// 4. Populate UI properties based on the derived profile.
136+
// 3. Populate UI properties based on the derived profile.
139137
// We use smooth curves here without the random 'hash' jitter for a cleaner UI.
140138
let peakHourStart = 9;
141139
let peakHourEnd = 17;
142140
let hourlyDistribution = new Array(24).fill(0);
143141
let activeWeekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
144142

145-
if (profileName === 'Night Owl 🌙') {
146-
peakHourStart = 22;
147-
peakHourEnd = 2;
148-
hourlyDistribution = Array.from({ length: 24 }, (_, h) => {
149-
const distFromMidnight = Math.min(Math.abs(h - 23), Math.abs(h + 1));
150-
return Math.max(8, Math.round(100 - distFromMidnight * 9.5));
151-
});
152-
activeWeekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
153-
} else if (profileName === 'Early Builder ☀') {
143+
if (profileName === 'Early Builder ☀') {
154144
peakHourStart = 6;
155145
peakHourEnd = 10;
156146
hourlyDistribution = Array.from({ length: 24 }, (_, h) => {
@@ -309,12 +299,12 @@ function getPersonalityTags(
309299
tags.push('Backend Architect ⚙️');
310300
}
311301

312-
if (coderProfile.profileName === 'Night Owl 🌙') {
313-
tags.push('Night Coder 🌙');
314-
} else if (coderProfile.profileName === 'Early Builder ☀') {
302+
if (coderProfile.profileName === 'Early Builder ☀') {
315303
tags.push('Early Builder ☀');
316304
} else if (coderProfile.profileName === 'Weekend Warrior 🚀') {
317305
tags.push('Weekend Warrior 🚀');
306+
} else if (coderProfile.profileName === 'Consistent Runner 🏃‍♂️') {
307+
tags.push('Consistent Runner 🏃‍♂️');
318308
}
319309

320310
return tags.slice(0, 3);
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
2+
import { BackgroundRefresh } from './background-refresh';
3+
import { getFullDashboardData } from '../../lib/github';
4+
5+
// Mock the external github fetch integration
6+
vi.mock('../../lib/github', () => ({
7+
getFullDashboardData: vi.fn(),
8+
}));
9+
10+
describe('BackgroundRefresh - Theme Contrast Equivalent (Mode Cohesion)', () => {
11+
let refresher: BackgroundRefresh;
12+
13+
beforeEach(() => {
14+
// Note: since it's a singleton, we use getInstance and reset
15+
// but the class is exported as a singleton instance 'backgroundRefresh'.
16+
// We can just import the class and use getInstance.
17+
refresher = BackgroundRefresh['getInstance']();
18+
refresher.reset();
19+
vi.clearAllMocks();
20+
});
21+
22+
it('Dual Environment Setup: seamlessly swaps between Fresh (Light) and Stale (Dark) cache modes', () => {
23+
vi.spyOn(Date, 'now').mockReturnValue(new Date('2024-01-01T12:00:00Z').getTime());
24+
25+
// Fresh Environment (Light Mode equivalent) - Synced 1 minute ago
26+
const freshTimestamp = new Date('2024-01-01T11:59:00Z').toISOString();
27+
expect(refresher.isStale(freshTimestamp)).toBe(false);
28+
29+
// Stale Environment (Dark Mode equivalent) - Synced 15 minutes ago
30+
const staleTimestamp = new Date('2024-01-01T11:45:00Z').toISOString();
31+
expect(refresher.isStale(staleTimestamp)).toBe(true);
32+
33+
vi.restoreAllMocks();
34+
});
35+
36+
it('Behavior Adaptation Cohesion: accurately transitions user from Idle to Active Job state', () => {
37+
// Mock getFullDashboardData to return a promise that doesn't resolve immediately
38+
let resolveJob: (value?: unknown) => void;
39+
(getFullDashboardData as Mock).mockReturnValue(
40+
new Promise((resolve) => {
41+
resolveJob = resolve;
42+
})
43+
);
44+
45+
// Idle State
46+
expect(refresher.isJobActive('transition_user')).toBe(false);
47+
48+
// Trigger action
49+
refresher.triggerRefresh('transition_user');
50+
51+
// Active State
52+
expect(refresher.isJobActive('transition_user')).toBe(true);
53+
54+
// Cleanup: Resolve the promise to not leave hanging promises
55+
resolveJob!(undefined);
56+
});
57+
58+
it('Contrast Verification (Concurrency): rigidly rejects colliding background jobs to preserve active state contrast', () => {
59+
let resolveJob1: (value?: unknown) => void;
60+
(getFullDashboardData as Mock).mockReturnValue(
61+
new Promise((resolve) => {
62+
resolveJob1 = resolve;
63+
})
64+
);
65+
66+
// Trigger first job
67+
refresher.triggerRefresh('collide_user');
68+
expect(getFullDashboardData).toHaveBeenCalledTimes(1);
69+
expect(refresher.isJobActive('collide_user')).toBe(true);
70+
71+
// Attempt to trigger overlapping second job
72+
refresher.triggerRefresh('collide_user');
73+
74+
// The integration should NOT be called a second time
75+
expect(getFullDashboardData).toHaveBeenCalledTimes(1);
76+
77+
resolveJob1!(undefined);
78+
});
79+
80+
it('Configuration Preservation (Error Resilience): clears active state smoothly upon integration failures without getting stuck', async () => {
81+
// Force a failure
82+
(getFullDashboardData as Mock).mockReturnValue(Promise.reject(new Error('Network failure')));
83+
84+
refresher.triggerRefresh('error_user');
85+
expect(refresher.isJobActive('error_user')).toBe(true);
86+
87+
// Wait for the microtask queue to process the catch/finally blocks
88+
await new Promise(process.nextTick);
89+
90+
// State should have smoothly transitioned back to idle
91+
expect(refresher.isJobActive('error_user')).toBe(false);
92+
});
93+
94+
it('Foreground/Background Cohesion (Sanitization): flawlessly aligns active states across disparate formatting inputs', () => {
95+
(getFullDashboardData as Mock).mockReturnValue(Promise.resolve());
96+
97+
// Start job with messy formatting
98+
refresher.triggerRefresh(' Messy_User ');
99+
100+
// Check state with clean formatting
101+
expect(refresher.isJobActive('messy_user')).toBe(true);
102+
103+
// Check state with different messy formatting
104+
expect(refresher.isJobActive(' MESSY_USER ')).toBe(true);
105+
});
106+
});

0 commit comments

Comments
 (0)