merge: hotfix-006 dashboard assignees
This commit is contained in:
14
.qa/HOTFIX-006-review-1.md
Normal file
14
.qa/HOTFIX-006-review-1.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# HOTFIX-006 review iteration 1
|
||||
- reviewed_at: 2026-04-05 09:31 KST
|
||||
- passed: false
|
||||
- blocking:
|
||||
- frontend/app/projects/[id]/page.tsx: HOTFIX HISTORY가 label만 렌더링하고 summary/description 데이터를 전혀 표시하지 않음
|
||||
- frontend/app/projects/[id]/page.tsx: ASSIGNED NODES가 실제 assignee 기반이 아니라 4자매 전체 하드코딩
|
||||
- backend build 실패: PrismaService 타입 에러 다수로 `npm run build` 불통
|
||||
- non_blocking:
|
||||
- frontend/app/page.tsx: 모바일 차트는 min-height만 추가됐고 가로 스크롤/최소 폭 보장이 없어 설계의 scroll-safe 대응이 불충분함
|
||||
- backend/src/projects/projects.service.ts: history 응답이 label만 내려줘 상세 설명 렌더링 요구를 충족하지 못함
|
||||
- subagents:
|
||||
- code-reviewer: not run (agent unavailable in current environment)
|
||||
- security-auditor: not run (agent unavailable in current environment)
|
||||
- qa-tester: not run (agent unavailable in current environment)
|
||||
40
.qa/HOTFIX-006-review-2.md
Normal file
40
.qa/HOTFIX-006-review-2.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# HOTFIX-006 review iteration 2
|
||||
- reviewed_at: 2026-04-05 14:03 UTC
|
||||
- passed: true
|
||||
- repo: https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard
|
||||
- branch: hotfix/HOTFIX-006-dashboard-assignees
|
||||
|
||||
## checks
|
||||
- backend test: ✅ passed (`npx prisma generate` 후 jest 26 tests / 9 suites 통과)
|
||||
- backend build: ✅ passed (`npx prisma generate` 후 build 통과)
|
||||
- frontend build: ✅ passed
|
||||
- frontend lint: ⚠️ failed (repo-wide pre-existing `no-explicit-any` / unused vars 위주)
|
||||
- frontend visual check: ✅ passed
|
||||
- dashboard 홈에서 `SISTER LOAD`, `PROJECT PROGRESS` 그래프 정상 렌더링 확인
|
||||
- 프로젝트 상세에서 `HOTFIX HISTORY`, `ASSIGNED NODES` 정상 렌더링 확인
|
||||
- mock API 기준 ASSIGNED NODES 다중 assignee(`narang`, `darang`, `erang`) 반영 확인
|
||||
|
||||
## blocking issues
|
||||
- 없음
|
||||
|
||||
## non-blocking notes
|
||||
- clean install 직후 backend jest/build는 Prisma client 미생성 상태로 실패했고 `npx prisma generate` 후 정상화됨
|
||||
- frontend lint 실패는 이번 hotfix 전용 이슈가 아니라 repo-wide 타입/unused 규칙 위반 영향
|
||||
- `frontend/app/page.tsx`, `frontend/app/projects/[id]/page.tsx`의 `any` 사용은 후속 정리 권장
|
||||
|
||||
## subagent summaries
|
||||
- code-reviewer: PASS
|
||||
- 범위 이탈 없음
|
||||
- low: history fetch N+1 패턴, 일부 설계와의 시각적 차이(avatar → initial mark), any 타입 잔존
|
||||
- security-auditor: PASS
|
||||
- hotfix 범위 내 high 없음
|
||||
- repo-wide backend dependency audit 이슈는 기존 항목
|
||||
- qa-tester: PASS
|
||||
- iteration 1 blocking 이슈(HOTFIX HISTORY 미표시, ASSIGNED NODES 하드코딩, backend build 실패) 해소 확인
|
||||
- dashboard/project detail 화면 요구사항 충족 확인
|
||||
|
||||
## verdict
|
||||
- `.plans/design/ui/dashboard-design.md` 기준 그래프 렌더링 정상
|
||||
- `.plans/design/ui/project-detail-design.md` 기준 HOTFIX HISTORY / ASSIGNED NODES 렌더링 정상
|
||||
- `.plans` 범위 이탈 없음
|
||||
- HOTFIX-006 머지 가능
|
||||
40
.qa/HOTFIX-006-review-3.md
Normal file
40
.qa/HOTFIX-006-review-3.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# HOTFIX-006 review iteration 3
|
||||
- reviewed_at: 2026-04-05 14:16 UTC
|
||||
- passed: true
|
||||
- repo: https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard
|
||||
- branch: hotfix/HOTFIX-006-dashboard-assignees
|
||||
|
||||
## checks
|
||||
- backend test: ✅ passed (`npx prisma generate` 후 jest 26 tests / 9 suites 통과)
|
||||
- backend build: ✅ passed (`npx prisma generate` 후 build 통과)
|
||||
- frontend build: ✅ passed
|
||||
- frontend lint: ⚠️ failed (repo-wide pre-existing `no-explicit-any` / unused vars)
|
||||
- frontend visual check: ✅ passed
|
||||
- dashboard 홈에서 `SISTER LOAD`, `PROJECT PROGRESS` 그래프 정상 렌더링 확인
|
||||
- 프로젝트 상세에서 `HOTFIX HISTORY`, `ASSIGNED NODES` 정상 표시 확인
|
||||
- mock API 기준 `narang`, `darang`, `erang` 다중 assignee 표시 확인
|
||||
|
||||
## blocking issues
|
||||
- 없음
|
||||
|
||||
## non-blocking notes
|
||||
- clean install 직후 backend는 Prisma Client 생성 전이라 test/build 불가했고 `npx prisma generate` 후 정상화됨
|
||||
- `.plans` 변경 자체는 범위 이탈 없음. 다만 `HOTFIX-006.md` 계획 파일은 보이지 않아 추적성 관점에선 후속 보완 권장
|
||||
- frontend lint 실패는 이번 hotfix 전용 이슈가 아니라 repo-wide 기존 부채
|
||||
|
||||
## subagent summaries
|
||||
- code-reviewer: PASS
|
||||
- 범위 이탈 없음
|
||||
- non-blocking: `HOTFIX-006.md` 부재, history fetch N+1, 일부 any 타입/시각적 차이
|
||||
- security-auditor: PASS
|
||||
- hotfix 범위 내 high 없음
|
||||
- medium: history endpoint 병렬 fetch 과다 가능성
|
||||
- qa-tester: PASS
|
||||
- 그래프 렌더링 / ASSIGNED NODES / HOTFIX HISTORY 요구사항 충족 확인
|
||||
- 신규 sprint-sync spec 3건 포함 backend 테스트 통과 확인
|
||||
|
||||
## verdict
|
||||
- dashboard graph rendering: pass
|
||||
- project detail assigned nodes multi-assignee: pass
|
||||
- `.plans` 범위 이탈: pass
|
||||
- HOTFIX-006 머지 가능
|
||||
@@ -108,19 +108,43 @@ export class ProjectsService {
|
||||
const project = await this.getProjectMeta(id);
|
||||
const repoName = project.repoUrl.replace(/\/+$/, '').split('/').pop() ?? '';
|
||||
const files = await this.gitea.getRepoTree(repoName, '.plans/sprints/');
|
||||
return files
|
||||
.filter((f) => /(SPRINT-\d+|HOTFIX-\d+)\.md$/i.test(f))
|
||||
.map((f) => {
|
||||
const name = f.split('/').pop() ?? '';
|
||||
const sprint = name.match(/SPRINT-(\d+)/i);
|
||||
const hotfix = name.match(/HOTFIX-(\d+)/i);
|
||||
return {
|
||||
kind: sprint ? 'sprint' : 'hotfix',
|
||||
order: sprint ? parseInt(sprint[1], 10) : 1000 + parseInt(hotfix?.[1] ?? '0', 10),
|
||||
label: name.replace('.md', ''),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.order - b.order);
|
||||
return await Promise.all(
|
||||
files
|
||||
.filter((f) => /(SPRINT-\d+|HOTFIX-\d+)\.md$/i.test(f))
|
||||
.map(async (f) => {
|
||||
const name = f.split('/').pop() ?? '';
|
||||
const sprint = name.match(/SPRINT-(\d+)/i);
|
||||
const hotfix = name.match(/HOTFIX-(\d+)/i);
|
||||
let summary: string | null = null;
|
||||
let description: string | null = null;
|
||||
|
||||
try {
|
||||
const content = await this.gitea.getRawFile(repoName, f);
|
||||
const lines = (content ?? '').split('\n');
|
||||
const goalLine = lines.find((line) => line.startsWith('## 목표'));
|
||||
summary = goalLine?.replace('## 목표', '').trim() || null;
|
||||
|
||||
const taskIdx = lines.findIndex((line) => line.startsWith('## 태스크'));
|
||||
if (taskIdx >= 0) {
|
||||
description = lines
|
||||
.slice(taskIdx + 1)
|
||||
.find((line) => line.trim().startsWith('- '))
|
||||
?.replace(/^\s*-\s*/, '')
|
||||
.trim() ?? null;
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn(`History content fetch failed for ${f}`);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: sprint ? 'sprint' : 'hotfix',
|
||||
order: sprint ? parseInt(sprint[1], 10) : 1000 + parseInt(hotfix?.[1] ?? '0', 10),
|
||||
label: name.replace('.md', ''),
|
||||
summary,
|
||||
description,
|
||||
};
|
||||
}),
|
||||
).then((items) => items.sort((a, b) => a.order - b.order));
|
||||
}
|
||||
|
||||
private async getProjectMeta(id: number) {
|
||||
|
||||
104
backend/src/projects/sprint-sync.service.spec.ts
Normal file
104
backend/src/projects/sprint-sync.service.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SprintSyncService } from './sprint-sync.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { GiteaService } from '../gitea/gitea.service';
|
||||
|
||||
describe('SprintSyncService', () => {
|
||||
let service: SprintSyncService;
|
||||
|
||||
const mockPrisma = {
|
||||
project: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
sprint: {
|
||||
findFirst: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
task: {
|
||||
findFirst: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockGitea = {
|
||||
getRepoTree: jest.fn(),
|
||||
getRawFile: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SprintSyncService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: GiteaService, useValue: mockGitea },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SprintSyncService>(SprintSyncService);
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockPrisma.project.findUnique.mockResolvedValue({
|
||||
id: 1,
|
||||
repoUrl: 'https://gitea.example.com/hanarang/project-alpha',
|
||||
});
|
||||
mockPrisma.sprint.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.sprint.create.mockResolvedValue({ id: 101, status: 'pending' });
|
||||
mockPrisma.sprint.update.mockResolvedValue({ id: 101, status: 'pending' });
|
||||
mockPrisma.task.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.task.create.mockResolvedValue({});
|
||||
mockPrisma.task.update.mockResolvedValue({});
|
||||
|
||||
mockGitea.getRepoTree.mockImplementation(async (_repoName: string, path: string) => {
|
||||
if (path === '.plans/sprints/') return ['.plans/sprints/SPRINT-005.md'];
|
||||
if (path === '.qa/') return [];
|
||||
return [];
|
||||
});
|
||||
});
|
||||
|
||||
it('syncProjectSprints: 담당 라인 기준 assignee를 저장한다', async () => {
|
||||
mockGitea.getRawFile.mockResolvedValue(`# SPRINT-005: Infra\n\n## 목표\n테스트\n\n## 태스크\n\n### TASK-019: 인프라 배포\n- **담당:** 이랑이\n- **상태:** pending\n- **설명:** Nginx 프록시 + PM2 + SSL\n\n### TASK-020: QA 자동화\n- **담당:** 다랑이\n- **상태:** pending\n- **설명:** smoke test\n`);
|
||||
|
||||
await service.syncProjectSprints(1);
|
||||
|
||||
expect(mockPrisma.task.create).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
taskId: 'TASK-019',
|
||||
assignee: 'erang',
|
||||
}),
|
||||
}));
|
||||
expect(mockPrisma.task.create).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
taskId: 'TASK-020',
|
||||
assignee: 'darang',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('syncProjectSprints: 기존 task도 assignee를 갱신한다', async () => {
|
||||
mockPrisma.task.findFirst.mockResolvedValue({ id: 501, assignee: 'narang', status: 'pending' });
|
||||
mockGitea.getRawFile.mockResolvedValue(`# SPRINT-005: Infra\n\n## 목표\n테스트\n\n## 태스크\n\n### TASK-019: 인프라 배포\n- **담당:** 하랑이\n- **상태:** pending\n- **설명:** sync\n`);
|
||||
|
||||
await service.syncProjectSprints(1);
|
||||
|
||||
expect(mockPrisma.task.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 501 },
|
||||
data: expect.objectContaining({
|
||||
assignee: 'harang',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('syncProjectSprints: 담당 라인이 없으면 narang으로 fallback한다', async () => {
|
||||
mockGitea.getRawFile.mockResolvedValue(`# SPRINT-005: Infra\n\n## 목표\n테스트\n\n## 태스크\n\n### TASK-019: 인프라 배포\n- **상태:** pending\n- **설명:** sync\n`);
|
||||
|
||||
await service.syncProjectSprints(1);
|
||||
|
||||
expect(mockPrisma.task.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
assignee: 'narang',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -11,9 +11,42 @@ interface ParsedSprint {
|
||||
interface ParsedTask {
|
||||
taskId: string;
|
||||
title: string;
|
||||
assignee: string;
|
||||
status: 'pending' | 'done';
|
||||
}
|
||||
|
||||
const SISTER_NAME_MAP: Record<string, string> = {
|
||||
harang: 'harang',
|
||||
'하랑': 'harang',
|
||||
'하랑이': 'harang',
|
||||
narang: 'narang',
|
||||
'나랑': 'narang',
|
||||
'나랑이': 'narang',
|
||||
darang: 'darang',
|
||||
'다랑': 'darang',
|
||||
'다랑이': 'darang',
|
||||
erang: 'erang',
|
||||
'이랑': 'erang',
|
||||
'이랑이': 'erang',
|
||||
};
|
||||
|
||||
function parseTaskAssignee(taskLines: string[]): string {
|
||||
for (const rawLine of taskLines) {
|
||||
const line = rawLine.trim();
|
||||
const match = line.match(/^-\s*(?:\*\*)?(?:담당|assignee)\s*:(?:\*\*)?\s*(.+)$/i);
|
||||
if (!match) continue;
|
||||
|
||||
const assignee = match[1]
|
||||
.trim()
|
||||
.replace(/[()\[\],]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.find((token) => SISTER_NAME_MAP[token.toLowerCase()] ?? SISTER_NAME_MAP[token]);
|
||||
if (assignee) return SISTER_NAME_MAP[assignee.toLowerCase()] ?? SISTER_NAME_MAP[assignee];
|
||||
}
|
||||
|
||||
return 'narang';
|
||||
}
|
||||
|
||||
function parseSprintName(content: string, filename: string, number: number): string {
|
||||
// 첫 번째 heading에서 이름 추출: # Sprint XXX — 이름 / # SPRINT-XXX: 이름
|
||||
const headingMatch = content.match(/^#\s+(?:SPRINT-\d+|Sprint\s+\d+)[:\s—\-]+(.+)/m);
|
||||
@@ -32,18 +65,16 @@ function parseTasks(content: string, sprintStatus: 'pending' | 'in_progress' | '
|
||||
const seen = new Set<string>();
|
||||
const tasks: ParsedTask[] = [];
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index].trim();
|
||||
let match: RegExpMatchArray | null = null;
|
||||
let status: 'pending' | 'done' | null = null;
|
||||
|
||||
// ### TASK-001: title
|
||||
match = line.match(/^#{2,6}\s+(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
|
||||
if (match) {
|
||||
status = sprintStatus === 'done' ? 'done' : 'pending';
|
||||
}
|
||||
|
||||
// - [x] TASK-001 title / - [ ] TASK-001 title
|
||||
if (!match) {
|
||||
match = line.match(/^-\s*\[([ xX])\]\s*(TASK-\d+[A-Z]?)\s*[:\-]?\s*(.+)$/i);
|
||||
if (match) {
|
||||
@@ -51,7 +82,6 @@ function parseTasks(content: string, sprintStatus: 'pending' | 'in_progress' | '
|
||||
}
|
||||
}
|
||||
|
||||
// - TASK-001: title
|
||||
if (!match) {
|
||||
match = line.match(/^-\s*(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
|
||||
if (match) {
|
||||
@@ -59,7 +89,6 @@ function parseTasks(content: string, sprintStatus: 'pending' | 'in_progress' | '
|
||||
}
|
||||
}
|
||||
|
||||
// TASK-001: title
|
||||
if (!match) {
|
||||
match = line.match(/^(TASK-\d+[A-Z]?)\s*:\s*(.+)$/i);
|
||||
if (match) {
|
||||
@@ -72,8 +101,21 @@ function parseTasks(content: string, sprintStatus: 'pending' | 'in_progress' | '
|
||||
const taskId = (match[1] ?? match[2]).toUpperCase();
|
||||
const title = (match[2] ?? match[3] ?? '').trim();
|
||||
if (!taskId || seen.has(taskId)) continue;
|
||||
|
||||
const taskBlock: string[] = [];
|
||||
for (let lookahead = index + 1; lookahead < lines.length; lookahead += 1) {
|
||||
const nextLine = lines[lookahead].trim();
|
||||
if (/^#{2,6}\s+TASK-\d+[A-Z]?\s*:/i.test(nextLine) || /^-\s*\[?[ xX]?\]?\s*TASK-\d+[A-Z]?\s*[:\-]?/i.test(nextLine) || /^TASK-\d+[A-Z]?\s*:/i.test(nextLine)) {
|
||||
break;
|
||||
}
|
||||
if (/^##\s+/.test(nextLine) && !/^##\s+목표/.test(nextLine)) {
|
||||
break;
|
||||
}
|
||||
taskBlock.push(lines[lookahead]);
|
||||
}
|
||||
|
||||
seen.add(taskId);
|
||||
tasks.push({ taskId, title: title || taskId, status });
|
||||
tasks.push({ taskId, title: title || taskId, assignee: parseTaskAssignee(taskBlock), status });
|
||||
}
|
||||
|
||||
return tasks;
|
||||
@@ -190,7 +232,7 @@ export class SprintSyncService {
|
||||
sprintId,
|
||||
taskId: task.taskId,
|
||||
title: task.title,
|
||||
assignee: 'narang',
|
||||
assignee: task.assignee,
|
||||
status: task.status,
|
||||
},
|
||||
});
|
||||
@@ -199,6 +241,7 @@ export class SprintSyncService {
|
||||
where: { id: existingTask.id },
|
||||
data: {
|
||||
title: task.title,
|
||||
assignee: task.assignee,
|
||||
status: existingTask.status === 'done' ? 'done' : task.status,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,15 +10,15 @@ import {
|
||||
} from '@/components/ui/base';
|
||||
import { API_URL, POLL_INTERVAL_MS } from '@/lib/config';
|
||||
import { useSocket } from '@/lib/useSocket';
|
||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||
|
||||
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
|
||||
const StatusGrid = styled.section`display:grid;grid-template-columns:repeat(4,1fr);gap:var(--space-lg);@media (min-width:768px) and (max-width:1199px){grid-template-columns:repeat(2,1fr)}@media (max-width:767px){grid-template-columns:repeat(2,1fr);gap:var(--space-md)}`;
|
||||
const DataColumns = styled.div`display:grid;grid-template-columns:1.5fr 1fr;gap:var(--space-xxl);align-items:start;@media (max-width:1199px){grid-template-columns:1fr;gap:var(--space-xl)}}`;
|
||||
const ChartGrid = styled.section`display:grid;grid-template-columns:1fr 1fr;gap:var(--space-xl);margin-bottom:var(--space-xxl);@media (max-width:1199px){grid-template-columns:1fr;}`;
|
||||
const ChartCard = styled.div`border:1px solid var(--border-color);padding:var(--space-lg);`;
|
||||
const ChartGrid = styled.section`display:grid;grid-template-columns:1fr 1fr;gap:var(--space-xl);margin-bottom:var(--space-xxl);@media (max-width:1199px){grid-template-columns:1fr;}@media (max-width:767px){gap:var(--space-lg);}`;
|
||||
const ChartCard = styled.div`border:1px solid var(--border-color);padding:var(--space-lg);min-width:0;overflow:hidden;`;
|
||||
const ProjectList = styled.div`display:flex;flex-direction:column;`;
|
||||
const ProjectRow = styled(Link)`display:grid;grid-template-columns:40px 1fr auto;gap:var(--space-md);padding:var(--space-md) 0;border-bottom:1px solid #222;align-items:center;text-decoration:none;transition:opacity .15s;&:last-child{border-bottom:none}&:hover{opacity:.8}`;
|
||||
const ProjectMark = styled.div`width:32px;height:32px;border-radius:50%;border:1px solid var(--border-color);display:flex;align-items:center;justify-content:center;background:#111;color:var(--text-secondary);font-family:var(--font-mono);font-size:12px;flex-shrink:0;`;
|
||||
const ProjectDetails = styled.div`display:flex;flex-direction:column;gap:2px;min-width:0;`;
|
||||
const ProjectName = styled.div`font-size:15px;font-weight:500;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
|
||||
const ProjectDesc = styled.div`font-size:13px;color:var(--text-secondary);line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
|
||||
@@ -26,10 +26,10 @@ const WsIndicator = styled.span<{ $on:boolean }>`font-size:11px;font-family:var(
|
||||
const SkeletonCard = styled.div`height:142px;border:1px solid var(--border-color);background:#111;animation:${pulse} 1.4s ease-in-out infinite;`;
|
||||
const SkeletonLine = styled.div`height:14px;background:#111;animation:${pulse} 1.4s ease-in-out infinite;margin:10px 0;`;
|
||||
const ErrorBox = styled.div`padding:var(--space-md);border:1px solid #5a2a2a;color:#ff9b9b;font-size:13px;margin-bottom:var(--space-lg);`;
|
||||
const BarChart = styled.div`display:flex;align-items:flex-end;gap:10px;height:160px;margin-top:var(--space-lg);`;
|
||||
const BarCol = styled.div`display:flex;flex-direction:column;align-items:center;gap:8px;flex:1;`;
|
||||
const BarChart = styled.div`display:flex;align-items:stretch;gap:10px;height:160px;margin-top:var(--space-lg);min-width:0;overflow-x:auto;overflow-y:hidden;padding-bottom:var(--space-xs);-webkit-overflow-scrolling:touch;scrollbar-width:thin;@media (max-width:767px){gap:8px;height:140px;scrollbar-width:none;&::-webkit-scrollbar{display:none;}}`;
|
||||
const BarCol = styled.div`display:flex;flex-direction:column;align-items:center;justify-content:flex-end;gap:8px;flex:0 0 52px;min-width:52px;height:100%;@media (max-width:767px){flex-basis:48px;min-width:48px;}`;
|
||||
const Bar = styled.div<{ $height:number }>`width:100%;max-width:48px;height:${({$height})=>$height}%;background:var(--text-primary);min-height:${({$height})=>$height > 0 ? '6px' : '0'};`;
|
||||
const BarLabel = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);`;
|
||||
const BarLabel = styled.div`font-size:11px;color:var(--text-secondary);font-family:var(--font-mono);max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
|
||||
const SISTER_SHORT: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' };
|
||||
function sisterBracketValue(s: any): string { if (s.status === 'online') return '[ON]'; if (s.status === 'working') return '[RUN]'; if (s.status === 'offline') return '[--]'; return '[??]'; }
|
||||
function sisterBarWidth(s: any): number { if (typeof s.cpu === 'number') return Math.max(0, Math.min(100, s.cpu)); if (s.status === 'online') return 100; if (s.status === 'working') return 84; if (s.status === 'offline') return 0; return 12; }
|
||||
@@ -79,7 +79,7 @@ export default function DashboardPage() {
|
||||
<StatusGrid>
|
||||
{loading ? Array.from({ length: 4 }).map((_, i) => <SkeletonCard key={i} />) : sisters.map((s) => (
|
||||
<Card key={s.id ?? s.name} as={Link} href={`/sisters/${s.name}`} style={{ textDecoration: 'none' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta><SisterAvatar name={s.name} size={24} /></div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><LabelMeta><span>SYS:</span>{SISTER_SHORT[s.name] ?? s.name}</LabelMeta><LabelMeta>{String(Math.round(Number(s.cpu ?? sisterBarWidth(s)))).padStart(2, '0')}%</LabelMeta></div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-xs)' }}><BracketValue $dimmed={s.status === 'offline'}>{sisterBracketValue(s)}</BracketValue><LabelMeta>{sisterSubLabel(s)}</LabelMeta></div>
|
||||
<TechBar><TechBarFill $width={sisterBarWidth(s)} /></TechBar>
|
||||
</Card>
|
||||
@@ -89,11 +89,11 @@ export default function DashboardPage() {
|
||||
<ChartGrid>
|
||||
<ChartCard>
|
||||
<SectionTitle><span>SISTER LOAD</span><LabelMeta>CPU %</LabelMeta></SectionTitle>
|
||||
<BarChart>{sisters.map((s) => <BarCol key={s.name}><Bar $height={Math.max(4, Number(s.cpu ?? 0))} /><BarLabel>{SISTER_SHORT[s.name] ?? s.name}</BarLabel></BarCol>)}</BarChart>
|
||||
<BarChart>{(sisters.length > 0 ? sisters : [{ name: 'harang', cpu: 0 }, { name: 'narang', cpu: 0 }, { name: 'darang', cpu: 0 }, { name: 'erang', cpu: 0 }]).map((s) => <BarCol key={s.name}><Bar $height={Math.max(4, Number(s.cpu ?? 0))} /><BarLabel>{SISTER_SHORT[s.name] ?? s.name}</BarLabel></BarCol>)}</BarChart>
|
||||
</ChartCard>
|
||||
<ChartCard>
|
||||
<SectionTitle><span>PROJECT PROGRESS</span><LabelMeta>TOP {projects.length}</LabelMeta></SectionTitle>
|
||||
<BarChart>{projects.slice(0, 6).map((p) => <BarCol key={p.id}><Bar $height={Math.max(4, Number(p.progress ?? 0))} /><BarLabel>{p.name.slice(0, 6)}</BarLabel></BarCol>)}</BarChart>
|
||||
<BarChart>{(projects.length > 0 ? projects.slice(0, 6) : [{ id: 'empty-1', name: 'EMPTY', progress: 0 }]).map((p) => <BarCol key={p.id}><Bar $height={Math.max(4, Number(p.progress ?? 0))} /><BarLabel>{p.name.slice(0, 6)}</BarLabel></BarCol>)}</BarChart>
|
||||
</ChartCard>
|
||||
</ChartGrid>
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function DashboardPage() {
|
||||
<SectionTitle><span>ONGOING PROJECTS</span><LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta></SectionTitle>
|
||||
<ProjectList>
|
||||
{loading ? Array.from({ length: 4 }).map((_, i) => <SkeletonLine key={i} />) : projects.length === 0 ? <div style={{ color: 'var(--text-secondary)', fontSize: '13px', padding: 'var(--space-md) 0' }}>등록된 프로젝트 없음</div> : projects.map((p) => (
|
||||
<ProjectRow key={p.id} href={`/projects/${p.id}`}><SisterAvatar name={p.ownerSister ?? 'narang'} size={32} /><ProjectDetails><ProjectName>{p.name}</ProjectName><ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc></ProjectDetails><LabelMeta>{p.progress ?? 0}%</LabelMeta></ProjectRow>
|
||||
<ProjectRow key={p.id} href={`/projects/${p.id}`}><ProjectMark>{String(p.name ?? 'P').slice(0, 1).toUpperCase()}</ProjectMark><ProjectDetails><ProjectName>{p.name}</ProjectName><ProjectDesc>{p.description ?? p.currentSprint ?? ''}</ProjectDesc></ProjectDetails><LabelMeta>{p.progress ?? 0}%</LabelMeta></ProjectRow>
|
||||
))}
|
||||
</ProjectList>
|
||||
</section>
|
||||
|
||||
@@ -6,6 +6,7 @@ import styled from 'styled-components';
|
||||
import Link from 'next/link';
|
||||
import { LabelMeta, PageTitle, SectionTitle, TechBar, TechBarFill } from '@/components/ui/base';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||
|
||||
// ─── Styled ───
|
||||
const Breadcrumb = styled.div`
|
||||
@@ -262,6 +263,33 @@ const NodeMiniCard = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
`;
|
||||
|
||||
const NodeInfo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const NodeLabel = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const NodeName = styled.div`
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
const NodeRole = styled.div`
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
`;
|
||||
|
||||
const NodeDot = styled.div`
|
||||
@@ -309,6 +337,13 @@ function formatDate(str: string): string {
|
||||
return new Date(str).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
const NODE_META = {
|
||||
harang: { label: '하랑', role: 'PRIMARY' },
|
||||
narang: { label: '나랑', role: 'GEN' },
|
||||
darang: { label: '다랑', role: 'EVAL' },
|
||||
erang: { label: '이랑', role: 'INFRA' },
|
||||
} as const;
|
||||
|
||||
const TABS = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'commits', label: 'Commits' },
|
||||
@@ -376,8 +411,14 @@ export default function ProjectDetailPage() {
|
||||
}, [prFilter]);
|
||||
|
||||
const allTasks = tasks.flatMap((s: any) => s.tasks ?? []);
|
||||
const doneTasks = allTasks.filter((t: any) => t.status === 'done');
|
||||
const activeSprint = tasks.find((s: any) => s.status === 'in_progress');
|
||||
const assignedNodeNames = Array.from(new Set(allTasks.map((task: any) => task.assignee).filter(Boolean)));
|
||||
const assignedNodes = (assignedNodeNames.length > 0 ? assignedNodeNames : ['harang', 'narang', 'darang', 'erang']).map((name) => ({
|
||||
name,
|
||||
label: NODE_META[name as keyof typeof NODE_META]?.label ?? name,
|
||||
role: NODE_META[name as keyof typeof NODE_META]?.role ?? 'NODE',
|
||||
}));
|
||||
const hotfixEntries = history.filter((entry: any) => entry.kind === 'hotfix');
|
||||
|
||||
if (loading) return <Empty>LOADING...</Empty>;
|
||||
if (!project) return <Empty>PROJECT NOT FOUND</Empty>;
|
||||
@@ -437,16 +478,16 @@ export default function ProjectDetailPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{history.filter((entry: any) => entry.kind === 'hotfix').length > 0 && (
|
||||
{hotfixEntries.length > 0 && (
|
||||
<>
|
||||
<SectionTitle style={{ marginBottom: 'var(--space-md)' }}>HOTFIX HISTORY</SectionTitle>
|
||||
<PhaseTimeline style={{ marginBottom: 'var(--space-xl)' }}>
|
||||
{history.filter((entry: any) => entry.kind === 'hotfix').map((entry: any) => (
|
||||
{hotfixEntries.map((entry: any) => (
|
||||
<PhaseItem key={entry.label}>
|
||||
<PhaseMeta>HOTFIX</PhaseMeta>
|
||||
<PhaseBox $active={false}>
|
||||
<PhaseName>{entry.label}</PhaseName>
|
||||
<PhaseDesc>보정/수정 이력</PhaseDesc>
|
||||
<PhaseDesc>{entry.summary ?? entry.description ?? '보정/수정 이력'}</PhaseDesc>
|
||||
</PhaseBox>
|
||||
</PhaseItem>
|
||||
))}
|
||||
@@ -455,9 +496,15 @@ export default function ProjectDetailPage() {
|
||||
)}
|
||||
<SectionTitle style={{ marginBottom: 'var(--space-md)' }}>ASSIGNED NODES</SectionTitle>
|
||||
<NodeStack>
|
||||
{['harang', 'narang', 'darang', 'erang'].map((name) => (
|
||||
<NodeMiniCard key={name}>
|
||||
<LabelMeta>{name === 'harang' ? '하랑' : name === 'narang' ? '나랑' : name === 'darang' ? '다랑' : '이랑'} [{name === 'harang' ? 'PRIMARY' : name === 'narang' ? 'GEN' : name === 'darang' ? 'EVAL' : 'INFRA'}]</LabelMeta>
|
||||
{assignedNodes.map((node) => (
|
||||
<NodeMiniCard key={node.name}>
|
||||
<NodeInfo>
|
||||
<SisterAvatar name={node.name} size={28} />
|
||||
<NodeLabel>
|
||||
<NodeName>{node.label}</NodeName>
|
||||
<NodeRole>[{node.role}]</NodeRole>
|
||||
</NodeLabel>
|
||||
</NodeInfo>
|
||||
<NodeDot />
|
||||
</NodeMiniCard>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user