Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0d5136d
fix: SVG radius 적용
with-developer Aug 12, 2024
0195948
fix: TOP N%가 overallTopPercentage로 적용되고 있던 버그 수정
with-developer Aug 12, 2024
46bcf48
fix: 사용자 닉네임으로 상태 정보를 받아올 수 있도록 변경
with-developer Aug 12, 2024
f97076b
feat: Vercel cold start로 인해 간헐적 svg가 렌더링 되지 않는 문제 해결
with-developer Aug 13, 2024
2f1f070
Merge branch 'main' into dev
with-developer Aug 13, 2024
7ed627e
chore: Vercel 서버를 위한 웜업 라우트 추가
with-developer Oct 10, 2024
7f87d24
chore: vercel hobby 플랜으로 인해 cron jobs를 10분마다 실행할 수 없어서 제거.
with-developer Oct 10, 2024
babe515
feat: 다크 테마 지원 추가
with-developer Jan 19, 2026
57ac473
test: 다크 테마 테스트 추가
with-developer Jan 19, 2026
1a0521f
docs: 테마 미리보기 표 형식으로 개선
with-developer Jan 19, 2026
7877c33
Merge branch 'main' into feat/dark-theme
with-developer Jan 19, 2026
04ebb6b
merge: main 브랜치 병합
with-developer Jan 20, 2026
406cb40
Merge branch 'feat/dark-theme' into dev
with-developer Jan 20, 2026
4178e04
feat: 메인페이지 UI 변경
with-developer Jan 20, 2026
d5632bb
feat: 커스텀 드롭다운 UI 적용
with-developer Jan 20, 2026
d3d907b
feat: 메인페이지 UI 개선 (코드 섹션 제목, 미리보기 연동)
with-developer Jan 20, 2026
c1136a8
merge: feat/dark-theme 브랜치 병합 (메인페이지 UI 개선)
with-developer Jan 20, 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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,28 @@ README에 아래 코드를 추가하고 `사용자명`을 본인의 Dreamhack

---

## Themes

`theme` 파라미터로 테마를 변경할 수 있습니다.

```markdown
![Dreamhack Stats](https://dreamhack-readme-stats.vercel.app/api/stats?username=사용자명&theme=dark)
```

| Theme | Wargame Stats | Most Solved Categories |
|-------|---------------|------------------------|
| `light` | ![Stats Light](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness&theme=light) | ![Categories Light](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=weakness&theme=light) |
| `dark` | ![Stats Dark](https://dreamhack-readme-stats.vercel.app/api/stats?username=weakness&theme=dark) | ![Categories Dark](https://dreamhack-readme-stats.vercel.app/api/most-solved?username=weakness&theme=dark) |

---

## Features

| Feature | Description |
|---------|-------------|
| **Wargame Stats** | 해결한 문제 수, 랭킹, 점수, TOP % 표시 |
| **Category Chart** | 카테고리별 점수 분포를 파이 차트로 시각화 |
| **Themes** | Light/Dark 테마 지원 |
| **Auto Update** | 실시간으로 최신 통계 반영 |
| **Caching** | Redis 캐싱으로 빠른 응답 속도 |

Expand Down
45 changes: 45 additions & 0 deletions src/__tests__/pages/api/stats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,52 @@ describe('stats API 엔드포인트 테스트', () => {
wargame_rank: `${mockUserData.wargame.rank}/${mockLastRank}`,
wargameRankPercentage: '20.00',
wargame_score: mockUserData.wargame.score,
}, 'light');
});

it('dark 테마 파라미터로 SVG를 생성해야 함', async () => {
// 모킹된 데이터 설정
const mockUserId = 20691;
const mockLastRank = 1000;
const mockUserData: TuserData = {
nickname: 'weakness',
contributions: { level: 1, rank: 100 },
exp: 1000,
total_wargame: 50,
wargame: { solved: 50, rank: 200, score: 5000 },
ctf: { rank: 300, tier: 'Gold', rating: 2000 },
profile_image: 'image.jpg'
};
const mockSvg = '<svg>Mock Dark SVG</svg>';

// 모킹된 함수 구현
(dreamhackUtils.getUserId as jest.Mock).mockResolvedValueOnce(mockUserId);
(dreamhackUtils.getLastRank as jest.Mock).mockResolvedValueOnce(mockLastRank);
(dreamhackUtils.getUserData as jest.Mock).mockResolvedValueOnce(mockUserData);
(dreamhackUtils.calculateTopPercentage as jest.Mock).mockReturnValueOnce('20.00');
(generateSvgUtils.generateStatsSvg as jest.Mock).mockReturnValueOnce(mockSvg);

// HTTP 요청 모킹 (theme=dark 포함)
const { req, res } = createMocks({
method: 'GET',
query: {
username: 'weakness',
theme: 'dark',
},
});

// API 핸들러 호출
await handler(req, res);

// 응답 검증
expect(res._getStatusCode()).toBe(200);
expect(res._getData()).toBe(mockSvg);

// dark 테마로 호출되었는지 확인
expect(generateSvgUtils.generateStatsSvg).toHaveBeenCalledWith(
expect.objectContaining({ nickname: 'weakness' }),
'dark'
);
});

it('사용자 이름이 없을 때 400 에러를 반환해야 함', async () => {
Expand Down
34 changes: 34 additions & 0 deletions src/__tests__/utils/generateCategorySvg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,38 @@ describe('generateCategorySvg 유틸리티 함수 테스트', () => {
expect(percentLabels.length).toBe(1);
expect(percentLabels[0]).toContain('93%');
});

it('light 테마(기본)로 SVG를 생성해야 함', () => {
const mockStats: TCategoryStats = {
nickname: 'weakness',
total_score: 5000,
categories: [
{ name: 'web', score: 2000, rank: 50, color: '#ff6b6b' }
]
};

const result = generateCategorySvg(mockStats);

// light 테마 색상 확인
expect(result).toContain('fill="#ffffff"'); // background
expect(result).toContain('fill="#f8fafc"'); // cardBackground
expect(result).toContain('stroke="#e2e8f0"'); // border
});

it('dark 테마로 SVG를 생성해야 함', () => {
const mockStats: TCategoryStats = {
nickname: 'weakness',
total_score: 5000,
categories: [
{ name: 'web', score: 2000, rank: 50, color: '#ff6b6b' }
]
};

const result = generateCategorySvg(mockStats, 'dark');

// dark 테마 색상 확인
expect(result).toContain('fill="#0d1117"'); // background
expect(result).toContain('fill="#21262d"'); // cardBackground
expect(result).toContain('stroke="#30363d"'); // border
});
});
44 changes: 31 additions & 13 deletions src/__tests__/utils/generateStatsSvg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { generateStatsSvg } from '../../utils/generateStatsSvg';
import { Tstats } from '../../types';

describe('generateStatsSvg 유틸리티 함수 테스트', () => {
it('유효한 통계 데이터로 SVG를 생성해야 함', () => {
const mockStats: Tstats = {
nickname: 'testuser',
wargame_solved: 50,
wargame_rank: '200/1000',
wargameRankPercentage: '20.00',
wargame_score: 5000
};
const mockStats: Tstats = {
nickname: 'testuser',
wargame_solved: 50,
wargame_rank: '200/1000',
wargameRankPercentage: '20.00',
wargame_score: 5000
};

it('유효한 통계 데이터로 SVG를 생성해야 함', () => {
const result = generateStatsSvg(mockStats);

// SVG 문자열이 반환되었는지 확인
Expand All @@ -26,32 +26,50 @@ describe('generateStatsSvg 유틸리티 함수 테스트', () => {
});

it('특수 문자가 포함된 사용자 이름을 올바르게 처리해야 함', () => {
const mockStats: Tstats = {
const specialStats: Tstats = {
nickname: 'test<user>',
wargame_solved: 50,
wargame_rank: '200/1000',
wargameRankPercentage: '20.00',
wargame_score: 5000
};

const result = generateStatsSvg(mockStats);
const result = generateStatsSvg(specialStats);

// 특수 문자가 포함된 사용자 이름이 SVG에 포함되어 있는지 확인
expect(result).toContain('test<user>');
});

it('긴 사용자 이름을 처리할 수 있어야 함', () => {
const mockStats: Tstats = {
const longNameStats: Tstats = {
nickname: 'verylongusernamethatmightcauseissueswithsvgrendering',
wargame_solved: 50,
wargame_rank: '200/1000',
wargameRankPercentage: '20.00',
wargame_score: 5000
};

const result = generateStatsSvg(mockStats);
const result = generateStatsSvg(longNameStats);

// 긴 사용자 이름이 SVG에 포함되어 있는지 확인
expect(result).toContain('verylongusernamethatmightcauseissueswithsvgrendering');
});

it('light 테마(기본)로 SVG를 생성해야 함', () => {
const result = generateStatsSvg(mockStats);

// light 테마 색상 확인
expect(result).toContain('fill="#ffffff"'); // background
expect(result).toContain('fill="#f8fafc"'); // cardBackground
expect(result).toContain('stroke="#e2e8f0"'); // border
});

it('dark 테마로 SVG를 생성해야 함', () => {
const result = generateStatsSvg(mockStats, 'dark');

// dark 테마 색상 확인
expect(result).toContain('fill="#0d1117"'); // background
expect(result).toContain('fill="#21262d"'); // cardBackground
expect(result).toContain('stroke="#30363d"'); // border
});
});
13 changes: 8 additions & 5 deletions src/pages/api/most-solved.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { TCategoryData, TCategoryStats } from '../../types';
import { TCategoryData, TCategoryStats, Theme } from '../../types';
import { generateCategorySvg } from '../../utils/generateCategorySvg';
import { getUserId, getUserData } from '../../utils/dreamhack';

Expand Down Expand Up @@ -31,12 +31,15 @@ const defaultColors = [

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
console.time('⏱️ 전체 API 실행 시간');
const { username } = req.query;
const { username, theme: themeParam } = req.query;

if (!username || typeof username !== 'string') {
return res.status(400).json({ error: 'Username is required' });
}

// 테마 파라미터 검증
const theme: Theme = themeParam === 'dark' ? 'dark' : 'light';

try {
// 개별 API 호출 시간 측정
const userId = await measureTime('getUserId', () => getUserId(username as string));
Expand Down Expand Up @@ -69,8 +72,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
total_score: wargame.score || 0,
categories: []
};
const svg = generateCategorySvg(emptyStats);

const svg = generateCategorySvg(emptyStats, theme);
res.setHeader('Content-Type', 'image/svg+xml');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
console.timeEnd('⏱️ 전체 API 실행 시간');
Expand Down Expand Up @@ -99,7 +102,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
categories
};

const svg = generateCategorySvg(categoryStats);
const svg = generateCategorySvg(categoryStats, theme);
console.timeEnd('⏱️ 데이터 가공 및 SVG 생성');

res.setHeader('Content-Type', 'image/svg+xml');
Expand Down
9 changes: 6 additions & 3 deletions src/pages/api/stats.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { Tstats } from '../../types';
import { Tstats, Theme } from '../../types';
import { generateStatsSvg } from '../../utils/generateStatsSvg';
import {
getLastRank,
Expand All @@ -20,12 +20,15 @@ const measureTime = async <T>(name: string, fn: () => Promise<T>): Promise<T> =>

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
console.time('⏱️ 전체 API 실행 시간');
const { username } = req.query;
const { username, theme: themeParam } = req.query;

if (!username || typeof username !== 'string') {
return res.status(400).json({ error: 'Username is required' });
}

// 테마 파라미터 검증
const theme: Theme = themeParam === 'dark' ? 'dark' : 'light';

try {
// 개별 API 호출 시간 측정
const userId = await measureTime('getUserId', () => getUserId(username as string));
Expand Down Expand Up @@ -64,7 +67,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
wargame_score: wargame.score,
};

const svg = generateStatsSvg(stats);
const svg = generateStatsSvg(stats, theme);
console.timeEnd('⏱️ 데이터 가공 및 SVG 생성');

res.setHeader('Content-Type', 'image/svg+xml');
Expand Down
Loading
Loading