Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cf717d9
fix: streak user parameter validation and error SVGs
armanpanigrahi59 Aug 14, 2026
8c85c70
Merge branch 'main' into fix/streak-user-validation
armanpanigrahi59 Aug 14, 2026
d91612e
style: fix prettier formatting
armanpanigrahi59 Aug 14, 2026
01a219f
feat: stream dashboard components with React Suspense (#71)
armanpanigrahi59 Aug 14, 2026
71b429a
style: fix prettier formatting issues
armanpanigrahi59 Aug 14, 2026
acf6139
fix: prefer-const in page.tsx
armanpanigrahi59 Aug 14, 2026
e929769
fix: resolve TypeScript type check errors in DashboardClient tests
armanpanigrahi59 Aug 14, 2026
fd6b6bd
style: fix prettier formatting in DashboardClient.type-compiler.test.tsx
armanpanigrahi59 Aug 14, 2026
505eb76
test: fix React suspense and mock warnings in test suite
armanpanigrahi59 Aug 14, 2026
ee2e601
style: fix prettier formatting issues in test files
armanpanigrahi59 Aug 14, 2026
e425170
fix: resolve ESLint errors and specific warnings
armanpanigrahi59 Aug 14, 2026
6ea91e7
fix: remove non-existent Repository type import
armanpanigrahi59 Aug 14, 2026
b5d511b
style: run prettier on timezone-mock.ts
armanpanigrahi59 Aug 14, 2026
e4d3ce2
feat: add multi-stage Docker builds (#876)
armanpanigrahi59 Aug 14, 2026
4678e4a
fix(api): validate invalid user query in /api/streak endpoint
armanpanigrahi59 Aug 15, 2026
bcb7643
fix: address false-positive sqli on login/signup routes (#554)
armanpanigrahi59 Aug 15, 2026
b7fbe27
fix: resolve CodeQL alerts with Zod validation
armanpanigrahi59 Aug 15, 2026
64a0eb9
fix(api): restore SVG error rendering for invalid user query
armanpanigrahi59 Aug 16, 2026
c7367e7
Merge branch 'main' into fix-sqli-login-signup-554
armanpanigrahi59 Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 41 additions & 7 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,20 +1,54 @@
# Dependencies & build outputs
node_modules
.next
.github
out
build
dist
coverage
.vitest

# Source control & CI
.git
.github
.gitignore
.gitattributes
.husky

coverage
# IDE & Editor settings
.vscode
.idea
*.swp
*.swo
.DS_Store

# Logs & debugging
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
eslint_report.json
eslint_errors.txt
tsc_report.txt

.env.local
# Environment files
.env
.env*.local
.env.production
.env.development

Dockerfile
# Local Docker & documentation
Dockerfile*
docker-compose*.yml
.dockerignore
README.md

.vscode
.idea
docs
CHANGELOG.md
CODE_OF_CONDUCT.md
CONTRIBUTING.md
LICENSE
SECURITY.md
THEMES.md
THEME_DEVELOPMENT.md

!.env.local.example
22 changes: 18 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,33 +1,47 @@
#base image
# Base stage for common dependencies and environment setup
FROM node:22-alpine AS base
# Install libc6-compat for compatibility with native libraries on Alpine Linux
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Dependencies stage: Install dependencies cleanly based on package-lock.json
FROM base AS deps
COPY package*.json ./
RUN npm ci

# Builder stage: Build Next.js application in standalone mode
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production

RUN npm run build

#production image
# Runner stage: Production image containing only runtime dependencies
FROM node:22-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public

# Set up runtime permissions for Next.js cache directory
RUN mkdir .next && chown nextjs:nodejs .next

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

CMD [ "node", "server.js" ]
CMD ["node", "server.js"]
40 changes: 21 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,54 +239,56 @@ npm run dev

Then visit: `http://localhost:3000/api/streak?user=YOUR_USERNAME`

## 🐳 Docker
## 🐳 Docker Multi-Stage Deployment

CommitPulse includes Docker support for consistent local development and production deployments.
CommitPulse features lightweight, optimized multi-stage Docker builds to streamline deployment and minimize container image size.

### Build Stages Architecture

- **`base`**: Node 22 Alpine base image with `libc6-compat` native library support.
- **`deps`**: Installs application dependencies cleanly via `npm ci`.
- **`builder`**: Compiles Next.js into a production standalone bundle (`.next/standalone`).
- **`runner`**: Minimal production environment running as an unprivileged non-root (`nextjs`) user listening on `0.0.0.0:3000`.

### Prerequisites

- Docker
- Docker Compose
- Docker Engine 20.10+
- Docker Compose v2+

### Local Development
### Local Development with Docker Compose

1. Copy the example environment file:

```bash
cp .env.local.example .env.local
```

2. Update the required environment variables in `.env.local` (such as `GITHUB_TOKEN`, `AUTH_SECRET`, and any optional integrations you plan to use).
2. Update the required environment variables in `.env.local` (such as `GITHUB_TOKEN`, `AUTH_SECRET`, and optional integrations).

3. Start the application and MongoDB:

```bash
docker compose up --build
```

The application will be available at:

```text
http://localhost:3000
```

MongoDB is automatically provisioned through Docker Compose. The `MONGODB_URI` is overridden to use the local MongoDB container, so no additional database configuration is required.
The application will be available at `http://localhost:3000`. MongoDB is automatically provisioned and linked (`MONGODB_URI=mongodb://mongodb:27017/commitpulse`).

### Production
### Multi-Stage Production Build

Build the production image:
Build the optimized production image using the `runner` target:

```bash
docker build -t commitpulse .
docker build --target runner -t commitpulse:latest .
```

Run the container:
Run the production container:

```bash
docker run \
docker run -d \
--name commitpulse \
--env-file .env.local \
-p 3000:3000 \
commitpulse
commitpulse:latest
```

### 🌐 Deploy to Vercel
Expand Down
40 changes: 3 additions & 37 deletions app/(root)/dashboard/DashboardPageWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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 */}
<div
style={{
opacity: ready ? 1 : 0,
transition: 'opacity 0.3s ease',
pointerEvents: ready ? 'auto' : 'none',
}}
>
{children}
</div>

{/* Overlay portalled into document.body β€” escapes every stacking context */}
{mounted &&
!ready &&
createPortal(<LoadingScreen onComplete={() => setReady(true)} />, document.body)}
</>
);
return <>{children}</>;
}
65 changes: 56 additions & 9 deletions app/(root)/dashboard/[username]/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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 ---
Expand Down Expand Up @@ -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(<Suspense fallback="loading">{PageContent}</Suspense>);
});
await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument());

expect(getFullDashboardData).toHaveBeenCalledWith(
'octocat',
Expand Down Expand Up @@ -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(<Suspense fallback="loading">{PageContent}</Suspense>);
});
await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument());

expect(getFullDashboardData).toHaveBeenCalledWith(
'octocat',
Expand All @@ -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(<Suspense fallback="loading">{PageContent}</Suspense>);
});
await waitFor(() => expect(screen.queryByText('loading')).not.toBeInTheDocument());

expect(getFullDashboardData).toHaveBeenCalledWith(
'octocat',
Expand All @@ -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(<Suspense fallback="loading">{PageContent}</Suspense>);
});
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' }),
Expand Down
Loading
Loading