merge: hotfix-006 dashboard assignees

This commit is contained in:
2026-04-05 16:01:38 +09:00
8 changed files with 350 additions and 38 deletions

View 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)

View 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 머지 가능

View 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 머지 가능

View File

@@ -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) {

View 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',
}),
}));
});
});

View File

@@ -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,
},
});

View File

@@ -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>

View File

@@ -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>
))}