Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 25 additions & 7 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ const EDITABLE_FIELDS = [
'actualEndDate',
'predecessors',
'budget',
'actualCost'
'actualCost',
'sprint',
'storyPoints'
];

const CSV_HEADERS = [
Expand Down Expand Up @@ -81,7 +83,9 @@ const CSV_HEADERS = [
'__depth',
'선행작업',
'예산',
'실투입비'
'실투입비',
'스프린트',
'스토리포인트'
];
const CSV_FORMULA_PREFIX_PATTERN = /^\s*[=+\-@|]/;
const UNSAFE_JSON_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
Expand All @@ -102,7 +106,9 @@ const CSV_FIELD_LABELS = Object.freeze(Object.assign(Object.create(null), {
actualEndDate: '실적종료일',
predecessors: '선행작업',
budget: '예산',
actualCost: '실투입비'
actualCost: '실투입비',
sprint: '스프린트',
storyPoints: '스토리포인트'
}));

const EDITOR_FIELD_TEST_IDS = Object.freeze(Object.assign(Object.create(null), {
Expand All @@ -120,7 +126,9 @@ const EDITOR_FIELD_TEST_IDS = Object.freeze(Object.assign(Object.create(null), {
actualEndDate: 'editor-actual-end',
predecessors: 'editor-predecessors',
budget: 'editor-budget',
actualCost: 'editor-actual-cost'
actualCost: 'editor-actual-cost',
sprint: 'editor-sprint',
storyPoints: 'editor-story-points'
}));

const LEGACY_PLANNED_END_FIELD = 'plannedEnd' + 'Ddate';
Expand Down Expand Up @@ -710,7 +718,9 @@ function renderEditorRow(anchorId) {
renderEditorField('실적종료일', 'actualEndDate', draft.actualEndDate, 'date'),
renderEditorField('선행작업', 'predecessors', draft.predecessors, 'text', false, '예: P1000,P2000 (선행 작업 ID/코드, 쉼표 구분)'),
renderEditorField('예산', 'budget', draft.budget, 'text', false, '예: 1000000 (원, 비용 EVM용)'),
renderEditorField('실투입비', 'actualCost', draft.actualCost, 'text', false, '예: 850000 (원)')
renderEditorField('실투입비', 'actualCost', draft.actualCost, 'text', false, '예: 850000 (원)'),
renderEditorField('스프린트', 'sprint', draft.sprint, 'text', false, '예: Sprint 3 (Agile/Hybrid)'),
renderEditorField('스토리포인트', 'storyPoints', draft.storyPoints, 'text', false, '예: 5')
].forEach((field) => editorGrid.appendChild(field));

const editorActions = document.createElement('div');
Expand Down Expand Up @@ -1125,6 +1135,8 @@ function createEmptyTaskDraft() {
predecessors: '',
budget: '',
actualCost: '',
sprint: '',
storyPoints: '',
isSynthetic: false
};
}
Expand Down Expand Up @@ -1611,7 +1623,9 @@ const createNormalizedExternalRecord = (task, defaults = {}) => ({
actualEndDate: task.actualEndDate || '',
predecessors: task.predecessors || defaults.predecessors || '',
budget: task.budget || defaults.budget || '',
actualCost: task.actualCost || defaults.actualCost || ''
actualCost: task.actualCost || defaults.actualCost || '',
sprint: task.sprint || defaults.sprint || '',
storyPoints: task.storyPoints || defaults.storyPoints || ''
});

function getPhaseKey(task, index) {
Expand Down Expand Up @@ -1758,7 +1772,9 @@ function exportCsv() {
task.depth,
task.predecessors || '',
task.budget || '',
task.actualCost || ''
task.actualCost || '',
task.sprint || '',
task.storyPoints || ''
];
});

Expand Down Expand Up @@ -1934,6 +1950,8 @@ function parseCsv(text) {
predecessors: validateCsvCell(readCsvCell(cells, headerMap, '선행작업'), 'predecessors'),
budget: validateCsvCell(readCsvCell(cells, headerMap, '예산'), 'budget'),
actualCost: validateCsvCell(readCsvCell(cells, headerMap, '실투입비'), 'actualCost'),
sprint: validateCsvCell(readCsvCell(cells, headerMap, '스프린트'), 'sprint'),
storyPoints: validateCsvCell(readCsvCell(cells, headerMap, '스토리포인트'), 'storyPoints'),
__id: validateCsvId(readCsvCell(cells, headerMap, '__id')),
__parentId: validateCsvParentId(readCsvCell(cells, headerMap, '__parentId')),
__depth: validateCsvDepth(readCsvCell(cells, headerMap, '__depth'))
Expand Down
256 changes: 256 additions & 0 deletions cloud-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,13 @@ function renderAuthUI() {
bar.appendChild(search);

if (getProjectId()) {
const spr = document.createElement('button');
spr.type = 'button';
spr.className = 'secondary-button';
spr.textContent = '스프린트';
spr.addEventListener('click', () => openSprintModal().catch((e) => toast(e.data?.error || e.message)));
bar.appendChild(spr);

const att = document.createElement('button');
att.type = 'button';
att.className = 'secondary-button';
Expand Down Expand Up @@ -675,6 +682,31 @@ function openReportModal() {
});
panel.appendChild(copy);

const ai = document.createElement('button');
ai.type = 'button';
ai.className = 'secondary-button';
ai.style.marginLeft = '8px';
ai.textContent = 'AI 요약';
ai.addEventListener('click', async () => {
ai.disabled = true;
ai.textContent = '분석 중…';
try {
const res = await api(`/api/projects/${getProjectId()}/ai/brief`, { method: 'POST' });
let box = document.getElementById('report-ai');
if (!box) {
box = document.createElement('pre');
box.id = 'report-ai';
box.style.whiteSpace = 'pre-wrap';
box.style.borderLeft = '3px solid var(--primary, #2563eb)';
box.style.paddingLeft = '10px';
panel.insertBefore(box, panel.querySelector('#report-body'));
}
box.textContent = `🤖 AI 브리핑\n${res.analysis}`;
} catch (e) { toast(e.data?.error || e.message); }
finally { ai.disabled = false; ai.textContent = 'AI 요약'; }
});
panel.appendChild(ai);

const pre = document.createElement('pre');
pre.id = 'report-body';
pre.style.whiteSpace = 'pre-wrap';
Expand Down Expand Up @@ -834,6 +866,230 @@ async function openPortfolioModal() {
panel.appendChild(wrap);
}

// --------------------------------------------------------------- sprints
// Agile/Hybrid 지표 (순수): 스프린트별 커밋/완료 스토리포인트와 팀 벨로시티.
// 작업 배정 = task.sprint(이름 일치), 추정 = task.storyPoints, 완료 = 실적 100%.
export function computeSprintStats(tasks, sprints, today) {
const leaf = (tasks || []).filter((t) => !t.isSynthetic);
const rows = (sprints || []).map((sp) => {
const mine = leaf.filter((t) => String(t.sprint || '').trim() === sp.name);
const pts = (t) => Number(t.storyPoints) || 0;
const committed = mine.reduce((n, t) => n + pts(t), 0);
const completed = mine.filter((t) => (Number(t.actualProgress) || 0) >= 100).reduce((n, t) => n + pts(t), 0);
const closed = Boolean(sp.endDate && today && sp.endDate < today);
return { id: sp.id, name: sp.name, startDate: sp.startDate, endDate: sp.endDate, goal: sp.goal, taskCount: mine.length, committed, completed, remaining: committed - completed, closed };
});
const closedWithWork = rows.filter((r) => r.closed && r.committed > 0);
const velocity = closedWithWork.length
? closedWithWork.reduce((n, r) => n + r.completed, 0) / closedWithWork.length
: null;
const assigned = new Set(rows.flatMap((r) => [r.name]));
const backlog = leaf.filter((t) => !String(t.sprint || '').trim() || !(sprints || []).some((sp) => sp.name === String(t.sprint).trim()));
return { rows, velocity, backlogCount: backlog.length };
}

// 번다운 (순수): 스프린트 기간의 일별 잔여 포인트 — ideal(선형 소진) vs
// actual(완료일 actualEndDate 기준; 완료일 없는 100% 작업은 오늘 완료로 간주).
export function computeBurndown(tasks, sprint, today) {
if (!sprint?.startDate || !sprint?.endDate || sprint.endDate < sprint.startDate) return null;
const leaf = (tasks || []).filter((t) => !t.isSynthetic && String(t.sprint || '').trim() === sprint.name);
const pts = (t) => Number(t.storyPoints) || 0;
const committed = leaf.reduce((n, t) => n + pts(t), 0);
if (committed <= 0) return null;
const days = [];
for (let d = new Date(sprint.startDate); ; d.setDate(d.getDate() + 1)) {
const iso = d.toISOString().slice(0, 10);
days.push(iso);
if (iso >= sprint.endDate) break;
if (days.length > 120) break; // 안전 상한
}
const n = days.length;
const ideal = days.map((_, i) => committed * (1 - (n === 1 ? 1 : i / (n - 1))));
const doneAt = (t) => t.actualEndDate || ((Number(t.actualProgress) || 0) >= 100 ? today : null);
const actual = days.map((day) => {
if (today && day > today) return null; // 미래는 미기록
const burned = leaf.filter((t) => { const d = doneAt(t); return d && d <= day; }).reduce((s2, t) => s2 + pts(t), 0);
return committed - burned;
});
return { days, committed, ideal, actual };
}

function renderBurndownSvg(bd) {
const W = 420, H = 110, PAD = 6;
const n = bd.days.length;
const x = (i) => PAD + (n === 1 ? 0 : (i / (n - 1)) * (W - 2 * PAD));
const y = (v) => H - PAD - (v / bd.committed) * (H - 2 * PAD);
const NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.setAttribute('role', 'img');
svg.setAttribute('aria-label', `번다운: 커밋 ${bd.committed}pt`);
svg.style.width = '100%';
svg.style.maxWidth = '460px';
const grid = document.createElementNS(NS, 'line');
grid.setAttribute('x1', PAD); grid.setAttribute('x2', W - PAD);
grid.setAttribute('y1', y(0)); grid.setAttribute('y2', y(0));
grid.setAttribute('stroke', '#e2e8f0');
svg.appendChild(grid);
const idealLine = document.createElementNS(NS, 'polyline');
idealLine.setAttribute('points', bd.ideal.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' '));
idealLine.setAttribute('fill', 'none');
idealLine.setAttribute('stroke', '#94a3b8');
idealLine.setAttribute('stroke-dasharray', '4 3');
svg.appendChild(idealLine);
const actualPts = bd.actual.map((v, i) => (v === null ? null : `${x(i).toFixed(1)},${y(v).toFixed(1)}`)).filter(Boolean);
if (actualPts.length) {
const actualLine = document.createElementNS(NS, 'polyline');
actualLine.setAttribute('points', actualPts.join(' '));
actualLine.setAttribute('fill', 'none');
actualLine.setAttribute('stroke', '#2563eb');
actualLine.setAttribute('stroke-width', '2');
svg.appendChild(actualLine);
}
return svg;
}

const METHODOLOGY_LABELS = { waterfall: 'Waterfall (예측형)', agile: 'Agile (적응형)', hybrid: 'Hybrid (혼합형)' };

async function openSprintModal() {
const pid = getProjectId();
if (!pid) return;
let modal = document.getElementById('sprint-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'sprint-modal';
modal.className = 'modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.addEventListener('click', () => modal.classList.add('hidden'));
const panel = document.createElement('div');
panel.className = 'modal-panel';
panel.id = 'sprint-panel';
modal.append(backdrop, panel);
document.body.appendChild(modal);
}
modal.classList.remove('hidden');
const panel = modal.querySelector('#sprint-panel');
panel.textContent = '';

const head = document.createElement('div');
head.className = 'modal-header';
const h2 = document.createElement('h2');
h2.textContent = '스프린트 (Agile / Hybrid)';
const close = document.createElement('button');
close.type = 'button';
close.className = 'icon-button close-button';
close.setAttribute('aria-label', '스프린트 닫기');
close.textContent = '✕';
close.addEventListener('click', () => modal.classList.add('hidden'));
head.append(h2, close);
panel.appendChild(head);

const data = await api(`/api/projects/${pid}/sprints`);

// 방법론 선택 — 프로젝트 메타로 저장
const mLabel = document.createElement('label');
mLabel.className = 'meta-field';
const mSpan = document.createElement('span');
mSpan.textContent = '프로젝트 방법론';
const mSel = document.createElement('select');
mSel.className = 'cloud-select';
mSel.id = 'methodology-select';
for (const [v, label] of Object.entries(METHODOLOGY_LABELS)) {
const opt = document.createElement('option');
opt.value = v;
opt.textContent = label;
if (v === (data.methodology || 'waterfall')) opt.selected = true;
mSel.appendChild(opt);
}
mSel.addEventListener('change', async () => {
try {
const cur = await api(`/api/projects/${pid}`);
await api(`/api/projects/${pid}`, { method: 'PUT', body: { methodology: mSel.value, version: cur.version } });
toast(`방법론: ${METHODOLOGY_LABELS[mSel.value]}`);
} catch (e) { toast(e.data?.error || e.message); }
});
mLabel.append(mSpan, mSel);
panel.appendChild(mLabel);

// 지표 + 목록
const stats = computeSprintStats(host?.getState?.()?.tasks || [], data.sprints, new Date().toISOString().slice(0, 10));
const summary = document.createElement('p');
summary.className = 'cpm-summary';
summary.textContent = `스프린트 ${stats.rows.length}개 · 벨로시티 ${stats.velocity === null ? 'N/A (종료 스프린트 없음)' : stats.velocity.toFixed(1) + 'pt'} · 백로그 ${stats.backlogCount}건`;
panel.appendChild(summary);

const list = document.createElement('ul');
list.className = 'team-list';
for (const r of stats.rows) {
const li = document.createElement('li');
const who = document.createElement('span');
who.className = 'team-who';
const period = r.startDate || r.endDate ? ` (${r.startDate}~${r.endDate})` : '';
who.textContent = `${r.name}${period} · ${r.taskCount}작업 · ${r.completed}/${r.committed}pt${r.closed ? ' · 종료' : ''}`;
const bdBtn = document.createElement('button');
bdBtn.type = 'button';
bdBtn.className = 'secondary-button';
bdBtn.textContent = '번다운';
bdBtn.addEventListener('click', () => {
const holder = document.getElementById('burndown-holder');
holder.textContent = '';
const bd = computeBurndown(host?.getState?.()?.tasks || [], r, new Date().toISOString().slice(0, 10));
if (!bd) { holder.textContent = '번다운을 그리려면 스프린트 기간과 스토리포인트가 필요합니다.'; return; }
const cap = document.createElement('p');
cap.className = 'evm-caption';
cap.textContent = `${r.name} 번다운 — 커밋 ${bd.committed}pt · 점선=이상적 소진, 실선=실제 잔여`;
holder.append(cap, renderBurndownSvg(bd));
});
const del = document.createElement('button');
del.type = 'button';
del.className = 'secondary-button team-remove';
del.textContent = '삭제';
del.addEventListener('click', () =>
api(`/api/projects/${pid}/sprints/${r.id}`, { method: 'DELETE' })
.then(() => openSprintModal()).catch((e) => toast(e.data?.error || e.message)));
li.append(who, bdBtn, del);
list.appendChild(li);
}
if (!stats.rows.length) {
const li = document.createElement('li');
li.textContent = '스프린트가 없습니다. 아래에서 추가하세요. (작업 배정: 편집기의 스프린트 필드)';
list.appendChild(li);
}
panel.appendChild(list);

const bdHolder = document.createElement('div');
bdHolder.id = 'burndown-holder';
panel.appendChild(bdHolder);

const form = document.createElement('form');
form.className = 'cloud-form';
const nameIn = document.createElement('input');
nameIn.type = 'text';
nameIn.placeholder = '스프린트 이름 (예: Sprint 3)';
nameIn.required = true;
const startIn = document.createElement('input');
startIn.type = 'date';
const endIn = document.createElement('input');
endIn.type = 'date';
const add = document.createElement('button');
add.type = 'submit';
add.className = 'primary-button';
add.textContent = '추가';
form.append(nameIn, startIn, endIn, add);
form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api(`/api/projects/${pid}/sprints`, { method: 'POST', body: { name: nameIn.value.trim(), startDate: startIn.value, endDate: endIn.value } });
toast('스프린트를 추가했습니다.');
openSprintModal();
} catch (err) { toast(err.data?.error || err.message); }
});
panel.appendChild(form);
}

// ----------------------------------------------------------- attachments
// 산출물 첨부: Clearfolio 통합 문서 뷰어로 업로드/열람. 서버가 프록시하므로
// 브라우저에는 Clearfolio 자격/시크릿이 노출되지 않는다.
Expand Down
20 changes: 20 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,26 @@ A PAT acts as your user across all your workspaces.
| `GET` | `/api/shared/:token` | **Anonymous** read-only project view (name/baseDate/tasks only) |
| `GET` | `/api/projects/:id/calendar.ics` | iCalendar feed of planned tasks (all-day VEVENTs). Calendar apps: pass `?token=` |

## Sprints & Methodology (Agile / Hybrid)

Projects carry `methodology` (`waterfall` default · `agile` · `hybrid`) — set it
via `PUT /api/projects/:id { methodology }`. Tasks join a sprint by name
(`task.sprint`) and are estimated with `task.storyPoints`; committed/completed
points and velocity are computed client-side (`computeSprintStats`). Hybrid =
waterfall metrics (EVM/CPM) and sprint metrics coexist on the same plan.

| Method | Path | Purpose |
| --- | --- | --- |
| `POST` | `/api/projects/:id/sprints` | `{ name, startDate?, endDate?, goal? }` (write roles) |
| `GET` | `/api/projects/:id/sprints` | List + project methodology |
| `DELETE` | `/api/projects/:id/sprints/:sid` | Remove a sprint |

## AI (contextual-orchestrator)

| Method | Path | Purpose |
| --- | --- | --- |
| `POST` | `/api/projects/:id/ai/brief` | 프로젝트 지표 요약 → LLM 경영진 브리핑(일정 판정·리스크·권고). Env: `ORCHESTRATOR_URL/TOKEN` (unset → mock) |

## Attachments (산출물 — Clearfolio 문서 뷰어)

Files attach to a project (optionally a task), convert via
Expand Down
Loading