diff --git a/app/(root)/dashboard/DashboardPageWrapper.tsx b/app/(root)/dashboard/DashboardPageWrapper.tsx
index db4246562..31003e3f5 100644
--- a/app/(root)/dashboard/DashboardPageWrapper.tsx
+++ b/app/(root)/dashboard/DashboardPageWrapper.tsx
@@ -1,47 +1,13 @@
'use client';
-import { useState, useSyncExternalStore } from 'react';
-import { createPortal } from 'react-dom';
-import LoadingScreen from './LoadingScreen';
-
interface DashboardPageWrapperProps {
children: React.ReactNode;
}
/**
- * Wraps dashboard page content and guarantees LoadingScreen plays its full
- * 3500ms animation regardless of how fast Next.js receives API data.
- *
- * The overlay is rendered via a React Portal directly into document.body —
- * this means it escapes ALL stacking contexts (navbar, layout wrappers, etc.)
- * and is unconditionally on top of everything on the page.
+ * Wraps dashboard page content.
+ * Now renders instantly to support React Suspense streaming.
*/
export default function DashboardPageWrapper({ children }: DashboardPageWrapperProps) {
- const [ready, setReady] = useState(false);
-
- const mounted = useSyncExternalStore(
- () => () => {},
- () => true,
- () => false
- );
-
- return (
- <>
- {/* Real page — renders immediately but stays invisible until animation ends */}
-
- {children}
-
-
- {/* Overlay portalled into document.body — escapes every stacking context */}
- {mounted &&
- !ready &&
- createPortal( setReady(true)} />, document.body)}
- >
- );
+ return <>{children}>;
}
diff --git a/app/(root)/dashboard/[username]/page.test.tsx b/app/(root)/dashboard/[username]/page.test.tsx
index 4ed6853c0..4ab49e919 100644
--- a/app/(root)/dashboard/[username]/page.test.tsx
+++ b/app/(root)/dashboard/[username]/page.test.tsx
@@ -1,8 +1,9 @@
import type { Metadata } from 'next';
-import { render, screen } from '@testing-library/react';
+import { Suspense } from 'react';
+import { render, screen, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import DashboardPage, { generateMetadata } from './page';
-import { getFullDashboardData } from '@/lib/github';
+import { getFullDashboardData, fetchUserProfile } from '@/lib/github';
const { mockNotFound } = vi.hoisted(() => ({
mockNotFound: vi.fn(),
@@ -21,7 +22,41 @@ vi.mock('next/navigation', () => ({
}));
vi.mock('@/lib/github', () => ({
- getFullDashboardData: vi.fn(),
+ getFullDashboardData: vi.fn().mockReturnValue(
+ Promise.resolve({
+ profile: {
+ login: 'octocat',
+ avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
+ html_url: 'https://github.com/octocat',
+ name: 'The Octocat',
+ bio: null,
+ company: '@github',
+ blog: 'https://github.blog',
+ location: 'San Francisco',
+ email: null,
+ hireable: null,
+ twitter_username: null,
+ public_repos: 8,
+ public_gists: 8,
+ followers: 3938,
+ following: 9,
+ created_at: '2011-01-25T18:44:36Z',
+ updated_at: '2023-01-22T12:13:14Z',
+ },
+ stats: {
+ currentStreak: 5,
+ peakStreak: 15,
+ totalContributions: 500,
+ },
+ activity: [],
+ languages: [],
+ commitTimes: [],
+ achievements: [],
+ recommendations: [],
+ })
+ ),
+ fetchUserProfile: vi.fn().mockResolvedValue({ type: 'User', name: '' }),
+ fetchUserRepos: vi.fn().mockResolvedValue([]),
}));
// --- Mocking Core UI Blocks ---
@@ -222,7 +257,10 @@ describe('DashboardPage', () => {
const DashboardContent = SuspenseTree.props.children.type;
const PageContent = await DashboardContent(SuspenseTree.props.children.props);
- render(PageContent);
+ await act(async () => {
+ render({PageContent});
+ });
+ await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument());
expect(getFullDashboardData).toHaveBeenCalledWith(
'octocat',
@@ -258,7 +296,10 @@ describe('DashboardPage', () => {
const DashboardContent = SuspenseTree.props.children.type;
const PageContent = await DashboardContent(SuspenseTree.props.children.props);
- render(PageContent);
+ await act(async () => {
+ render({PageContent});
+ });
+ await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument());
expect(getFullDashboardData).toHaveBeenCalledWith(
'octocat',
@@ -279,7 +320,10 @@ describe('DashboardPage', () => {
const DashboardContent = SuspenseTree.props.children.type;
const PageContent = await DashboardContent(SuspenseTree.props.children.props);
- render(PageContent);
+ await act(async () => {
+ render({PageContent});
+ });
+ await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument());
expect(getFullDashboardData).toHaveBeenCalledWith(
'octocat',
@@ -300,14 +344,17 @@ describe('DashboardPage', () => {
const DashboardContent = SuspenseTree.props.children.type;
const PageContent = await DashboardContent(SuspenseTree.props.children.props);
- render(PageContent);
+ await act(async () => {
+ render({PageContent});
+ });
+ await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument());
const trendView = screen.getByTestId('historical-trend-view');
expect(JSON.parse(trendView.getAttribute('data-prop') ?? '[]')).toEqual(mockData.activity);
});
- it('calls notFound when dashboard data fetch throws an error', async () => {
- vi.mocked(getFullDashboardData).mockRejectedValueOnce(new Error('User not found'));
+ it('calls notFound when fetchUserProfile throws an error', async () => {
+ vi.mocked(fetchUserProfile).mockRejectedValueOnce(new Error('Fetch failed'));
const SuspenseTree = await DashboardPage({
params: Promise.resolve({ username: 'missing-user' }),
diff --git a/app/(root)/dashboard/[username]/page.tsx b/app/(root)/dashboard/[username]/page.tsx
index 7063302b7..a1eda1ecc 100644
--- a/app/(root)/dashboard/[username]/page.tsx
+++ b/app/(root)/dashboard/[username]/page.tsx
@@ -136,36 +136,29 @@ async function DashboardContent({
const session = await auth();
const sessionUsername = (session?.user as { username?: string })?.username ?? null;
- let data;
-
+ let fallbackProfile;
try {
- data = await getFullDashboardData(username, {
+ fallbackProfile = await fetchUserProfile(username, {
bypassCache,
- from: period.from,
- to: period.to,
- rangeLabel: period.label,
token: userToken,
- excludeBots,
});
- } catch (error) {
- if (error instanceof Error && error.message.includes('not found')) {
- let fallbackProfile;
- try {
- fallbackProfile = await fetchUserProfile(username, {
- bypassCache,
- token: userToken,
- });
- } catch {
- return notFound();
- }
- if (fallbackProfile.type === 'Organization') {
- redirect(`/dashboard/org/${username}`);
- }
- return notFound();
- }
- throw error;
+ } catch {
+ return notFound();
}
+ if (fallbackProfile.type === 'Organization') {
+ redirect(`/dashboard/org/${username}`);
+ }
+
+ const data = getFullDashboardData(username, {
+ bypassCache,
+ from: period.from,
+ to: period.to,
+ rangeLabel: period.label,
+ token: userToken,
+ excludeBots,
+ });
+
let allRepos: RepoActivityInfo[] = [];
try {
const reposData = await fetchUserRepos(username, { bypassCache, token: userToken });
@@ -196,7 +189,7 @@ async function DashboardContent({
{
expect(response.headers.get('Content-Type')).toContain('image/svg+xml');
});
+ it('returns JSON 400 when the user parameter is missing and format=json', async () => {
+ const response = await GET(makeRequest({ format: 'json' }));
+ expect(response.status).toBe(400);
+ const body = await response.json();
+ expect(body).toHaveProperty('error');
+ expect(body.error).toContain('Missing user parameter');
+ expect(response.headers.get('Content-Type')).toContain('application/json');
+ });
+
+ it('returns 404 JSON for a nonexistent user when format=json', async () => {
+ vi.mocked(fetchGitHubContributions).mockRejectedValue(
+ new Error("Could not resolve to a User with the login of 'nonexistentuser'")
+ );
+ const response = await GET(makeRequest({ user: 'nonexistentuser', format: 'json' }));
+ expect(response.status).toBe(404);
+ const body = await response.json();
+ expect(body).toHaveProperty('error');
+ expect(response.headers.get('Content-Type')).toContain('application/json');
+ });
+
it('returns 400 when org parameter contains spaces and invalid characters', async () => {
const response = await GET(
makeRequest({ user: 'octocat', org: 'invalid_org_name_with_spaces' })
diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts
index 44e5e205c..cb45c205c 100644
--- a/app/api/streak/route.ts
+++ b/app/api/streak/route.ts
@@ -101,6 +101,21 @@ export async function GET(request: Request) {
Object.values(fieldErrors.fieldErrors).flat()[0] ??
fieldErrors.formErrors[0] ??
'Invalid parameters';
+
+ if (searchParams.get('format') === 'json') {
+ return NextResponse.json(
+ { error: firstError },
+ {
+ status: 400,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-store',
+ 'X-Request-ID': requestId,
+ },
+ }
+ );
+ }
+
const errTheme = resolveErrorTheme(searchParams);
const errorSvg = buildInlineErrorSVG(firstError, {
bg: errTheme.bg,
@@ -1035,7 +1050,7 @@ function buildErrorResponse(
}
if (isNotFound) {
- const match = message.match(/"([^"]+)"|login of '([^']+)'/);
+ const match = rawMessage.match(/"([^"]+)"|login of '([^']+)'/);
const fallbackTarget = parseResult.success
? parseResult.data.org || parseResult.data.user
: 'unknown';
diff --git a/app/burnout-analyzer/page.test.tsx b/app/burnout-analyzer/page.test.tsx
index 6ee7ee08c..eb7b7c089 100644
--- a/app/burnout-analyzer/page.test.tsx
+++ b/app/burnout-analyzer/page.test.tsx
@@ -20,8 +20,17 @@ vi.mock('next/navigation', () => ({
}),
}));
+vi.mock('framer-motion', async () => {
+ const actual = await vi.importActual('framer-motion');
+ return {
+ ...(actual as object),
+ AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ };
+});
+
describe('BurnoutAnalyzerPage repository input handling', () => {
beforeEach(() => {
+ vi.restoreAllMocks();
vi.clearAllMocks();
mockHistoryBack.mockReset();
mockRouterPush.mockReset();
@@ -115,14 +124,10 @@ describe('BurnoutAnalyzerPage repository input handling', () => {
}),
});
vi.stubGlobal('fetch', fetchMock);
- Object.defineProperty(window, 'history', {
- value: { length: 2, back: mockHistoryBack },
- configurable: true,
- });
- Object.defineProperty(document, 'referrer', {
- value: 'http://localhost/burnout-analyzer',
- configurable: true,
- });
+ vi.spyOn(window.history, 'length', 'get').mockReturnValue(2);
+ vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack);
+ vi.spyOn(window.history, 'pushState').mockImplementation(vi.fn());
+ vi.spyOn(document, 'referrer', 'get').mockReturnValue('http://localhost/burnout-analyzer');
render();
fireEvent.change(screen.getByPlaceholderText(/facebook\/react/i), {
@@ -154,14 +159,10 @@ describe('BurnoutAnalyzerPage repository input handling', () => {
}),
});
vi.stubGlobal('fetch', fetchMock);
- Object.defineProperty(window, 'history', {
- value: { length: 1, back: mockHistoryBack },
- configurable: true,
- });
- Object.defineProperty(document, 'referrer', {
- value: 'http://localhost/another-page',
- configurable: true,
- });
+ vi.spyOn(window.history, 'length', 'get').mockReturnValue(1);
+ vi.spyOn(window.history, 'back').mockImplementation(mockHistoryBack);
+ vi.spyOn(window.history, 'pushState').mockImplementation(vi.fn());
+ vi.spyOn(document, 'referrer', 'get').mockReturnValue('http://localhost/another-page');
render();
fireEvent.change(screen.getByPlaceholderText(/facebook\/react/i), {
diff --git a/components/dashboard/DashboardClient.tsx b/components/dashboard/DashboardClient.tsx
index 0b60ee194..77e07b68b 100644
--- a/components/dashboard/DashboardClient.tsx
+++ b/components/dashboard/DashboardClient.tsx
@@ -1,7 +1,7 @@
'use client';
import { copyToClipboard } from '@/utils/clipboard';
-import { useState, useEffect, useRef, useCallback, useSyncExternalStore } from 'react';
+import { useState, useRef, useEffect, useCallback, useSyncExternalStore, use } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import DashboardSkeleton from './DashboardSkeleton';
@@ -106,8 +106,9 @@ export interface DashboardData {
rawCommits?: string[];
}
-interface DashboardClientProps {
- initialData: DashboardData;
+export interface DashboardClientProps {
+ initialDataPromise?: Promise;
+ initialData?: DashboardData;
allRepoActivity?: RepoActivityInfo[];
username: string;
compareData?: DashboardData | null;
@@ -330,7 +331,8 @@ function getPersonalityTags(
}
export default function DashboardClient({
- initialData,
+ initialDataPromise,
+ initialData: initialDataProp,
allRepoActivity = [],
username,
compareData = null,
@@ -342,6 +344,10 @@ export default function DashboardClient({
() => false,
() => (process.env.NODE_ENV === 'test' ? false : true)
);
+
+ // Use React.use() if a promise is provided, otherwise use the direct object (for tests)
+ const initialData = initialDataProp || use(initialDataPromise!);
+
const [secondUserData, setSecondUserData] = useState(compareData);
const [activeTab, setActiveTab] = useState<'overview' | 'pr-insights' | 'ci-analytics'>(
'overview'
diff --git a/components/dashboard/DashboardClient.type-compiler.test.tsx b/components/dashboard/DashboardClient.type-compiler.test.tsx
index a9d0bcb87..249911acd 100644
--- a/components/dashboard/DashboardClient.type-compiler.test.tsx
+++ b/components/dashboard/DashboardClient.type-compiler.test.tsx
@@ -1,6 +1,10 @@
import { describe, it, expectTypeOf } from 'vitest';
import React, { ComponentProps } from 'react';
-import DashboardClient, { ProfileMetrics, CoderProfile } from './DashboardClient';
+import DashboardClient, {
+ ProfileMetrics,
+ CoderProfile,
+ DashboardClientProps,
+} from './DashboardClient';
import type { DashboardPeriod } from '@/utils/dashboardPeriod';
describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints Stability (Variation 10)', () => {
@@ -16,29 +20,31 @@ describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints
});
it('Use type-testing assertions (expectTypeOf) to enforce field property configurations: Infer unexported props', () => {
- // Extracting the props from the component
- type Props = ComponentProps;
+ type Props = DashboardClientProps;
// Assert the required fields
expectTypeOf().toHaveProperty('username').toBeString();
expectTypeOf().toHaveProperty('period').toEqualTypeOf();
// Test the internal initialData structure
- expectTypeOf().toHaveProperty('profile').toBeObject();
- expectTypeOf().toHaveProperty('username').toBeString();
+ expectTypeOf>().toHaveProperty('profile').toBeObject();
+ expectTypeOf['profile']>()
+ .toHaveProperty('username')
+ .toBeString();
});
it('Assert that invalid prop parameters are blocked during static type checking: Rejects missing props', () => {
- type Props = ComponentProps;
+ type Props = DashboardClientProps;
// Missing 'username', 'initialData', 'period'
expectTypeOf>().not.toMatchTypeOf();
- // 'username' must be a string, not a number
+ // 'initialData' must not be missing critical nested keys
expectTypeOf<{
- username: number;
- initialData: Props['initialData'];
+ username: string;
+ initialData: Omit, 'profile'>;
period: DashboardPeriod;
+ allRepoActivity: Props['allRepoActivity'];
}>().not.toMatchTypeOf();
});
@@ -58,6 +64,11 @@ describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints
/>
);
void validComponent;
+
+ // Testing the extraction logic in edge cases
+ expectTypeOf['activity']>().toMatchTypeOf<
+ Array<{ date: string; count: number; intensity: 0 | 1 | 2 | 3 | 4 }>
+ >();
});
it('Verify schema validation constraints return strict validation reports: CoderProfile strict unions', () => {
@@ -66,9 +77,8 @@ describe('DashboardClient - TypeScript Compiler Validation & Schema Constraints
'Early Builder ☀' | 'Weekend Warrior 🚀' | 'Consistent Runner 🏃♂️'
>();
- // intensity in activity array has strict union 0 | 1 | 2 | 3 | 4
- type Props = ComponentProps;
- type ActivityIntensity = Props['initialData']['activity'][0]['intensity'];
+ type Props = DashboardClientProps;
+ type ActivityIntensity = NonNullable['activity'][0]['intensity'];
expectTypeOf().toEqualTypeOf<0 | 1 | 2 | 3 | 4>();
});
});
diff --git a/lib/validations.streakParamsSchema.test.ts b/lib/validations.streakParamsSchema.test.ts
index 75c8fb777..85d645fda 100644
--- a/lib/validations.streakParamsSchema.test.ts
+++ b/lib/validations.streakParamsSchema.test.ts
@@ -65,15 +65,24 @@ describe('streakParamsSchema', () => {
expect(result.success).toBe(true);
});
+ it('accepts input with org but missing user', () => {
+ const result = streakParamsSchema.safeParse({ org: 'github' });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.org).toBe('github');
+ expect(result.data.user).toBe(''); // default empty string
+ }
+ });
+
// ── Invalid / negative cases ──────────────────────────────────────────────
- it('fails when user is missing', () => {
+ it('fails when both user and org are missing', () => {
const result = streakParamsSchema.safeParse({});
expect(result.success).toBe(false);
});
- it('fails when user is an empty string', () => {
- const result = streakParamsSchema.safeParse({ user: '' });
+ it('fails when user and org are empty strings', () => {
+ const result = streakParamsSchema.safeParse({ user: '', org: '' });
expect(result.success).toBe(false);
});
diff --git a/lib/validations.ts b/lib/validations.ts
index b1834c1d1..32734bcbb 100644
--- a/lib/validations.ts
+++ b/lib/validations.ts
@@ -233,8 +233,9 @@ const baseStreakParamsSchema = z.object({
// Required — missing user surfaces as "Missing" to match existing tests
user: z
.string({ error: 'Missing user parameter' })
- .min(1, { message: 'Missing user parameter' })
+ .default('')
.superRefine((val, ctx) => {
+ if (val === '') return;
const users = val.split(',').map((u) => u.trim());
if (users.length === 0) {
ctx.addIssue({
@@ -693,6 +694,11 @@ const baseStreakParamsSchema = z.object({
const TWO_YEARS_MS = 2 * 365.25 * 24 * 60 * 60 * 1000;
export const streakParamsSchema = baseStreakParamsSchema
+ .refine(
+ (data) =>
+ (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0),
+ { message: 'Missing user parameter', path: ['user'] }
+ )
.refine((data) => !data.from || !data.to || Date.parse(data.from) <= Date.parse(data.to), {
message: '"to" date must be after or equal to "from" date',
path: ['to'],
@@ -1240,6 +1246,11 @@ export const animatedStreakParamsSchema = baseStreakParamsSchema
.optional()
.transform((val) => val || 'rise'),
})
+ .refine(
+ (data) =>
+ (data.user && data.user.trim().length > 0) || (data.org && data.org.trim().length > 0),
+ { message: 'Missing user parameter', path: ['user'] }
+ )
.refine((data) => !data.from || !data.to || Date.parse(data.from) <= Date.parse(data.to), {
message: '"to" date must be after or equal to "from" date',
path: ['to'],
diff --git a/services/github/webhook-handler.ts b/services/github/webhook-handler.ts
index ef4760bee..0973faaf6 100644
--- a/services/github/webhook-handler.ts
+++ b/services/github/webhook-handler.ts
@@ -1,6 +1,5 @@
import { DistributedCache } from '@/lib/cache';
import { redactSecrets } from '@/lib/secretScanner';
-import type { CIWorkflowRun, CIInsights } from '@/types/ci-analytics';
interface WebhookPayload {
action?: string;
@@ -226,7 +225,7 @@ ${Object.entries(event.details)
.join('\n')}
`;
- console.log(`Email alert would be sent to ${email}:`, { subject, body });
+ console.info(`Email alert would be sent to ${email}:`, { subject, body });
} catch (error) {
console.error('Failed to send email alert:', error);
}
diff --git a/test-utils/timezone-mock.ts b/test-utils/timezone-mock.ts
index e0279d289..ba8313006 100644
--- a/test-utils/timezone-mock.ts
+++ b/test-utils/timezone-mock.ts
@@ -4,8 +4,6 @@
// Ensures that timezone-sensitive tests produce consistent results
// regardless of the system timezone (e.g., UTC on CI vs local dev timezone).
-import { vi } from 'vitest';
-
/**
* A map of IANA timezone identifiers to their UTC offsets in minutes.
* Positive values are east of UTC (ahead), negative values are west (behind).
diff --git a/utils/copyToClipboard.test.ts b/utils/copyToClipboard.test.ts
index ba4ec054a..baca7f3f1 100644
--- a/utils/copyToClipboard.test.ts
+++ b/utils/copyToClipboard.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { describe, it, expect, vi, afterEach } from 'vitest';
import { copyToClipboard } from './clipboard';
// ---------------------------------------------------------------------------