diff --git a/app.js b/app.js index 6fd028b7..6b4cdc7f 100644 --- a/app.js +++ b/app.js @@ -52,7 +52,9 @@ const EDITABLE_FIELDS = [ 'actualEndDate', 'predecessors', 'budget', - 'actualCost' + 'actualCost', + 'sprint', + 'storyPoints' ]; const CSV_HEADERS = [ @@ -81,7 +83,9 @@ const CSV_HEADERS = [ '__depth', '선행작업', '예산', - '실투입비' + '실투입비', + '스프린트', + '스토리포인트' ]; const CSV_FORMULA_PREFIX_PATTERN = /^\s*[=+\-@|]/; const UNSAFE_JSON_KEYS = new Set(['__proto__', 'constructor', 'prototype']); @@ -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), { @@ -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'; @@ -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'); @@ -1125,6 +1135,8 @@ function createEmptyTaskDraft() { predecessors: '', budget: '', actualCost: '', + sprint: '', + storyPoints: '', isSynthetic: false }; } @@ -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) { @@ -1758,7 +1772,9 @@ function exportCsv() { task.depth, task.predecessors || '', task.budget || '', - task.actualCost || '' + task.actualCost || '', + task.sprint || '', + task.storyPoints || '' ]; }); @@ -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')) diff --git a/cloud-sync.js b/cloud-sync.js index 5109b761..b735272b 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -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'; @@ -859,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 자격/시크릿이 노출되지 않는다. diff --git a/docs/api.md b/docs/api.md index 157d9921..1c668425 100644 --- a/docs/api.md +++ b/docs/api.md @@ -56,6 +56,20 @@ 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 | diff --git a/package.json b/package.json index 140fe49f..8dafedaa 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "coverage": "node scripts/ci/static_coverage_evidence.mjs coverage", "server": "node server/server.mjs", "test:api": "node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright test tests/e2e/cloud.spec.js" diff --git a/server/app.mjs b/server/app.mjs index e2d992da..cc5a26e7 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -241,7 +241,7 @@ app.post('/api/projects', requireAuth, async (c) => { app.get('/api/projects/:id', requireAuth, (c) => { const p = projectAccess(c.get('user').sub, c.req.param('id')); if (!p) return c.json({ error: 'not found' }, 404); - return c.json({ id: p.id, name: p.name, orgId: p.org_id, baseDate: p.base_date, tasks: JSON.parse(p.tasks_json), version: p.version }); + return c.json({ id: p.id, name: p.name, orgId: p.org_id, baseDate: p.base_date, methodology: p.methodology || 'waterfall', tasks: JSON.parse(p.tasks_json), version: p.version }); }); app.put('/api/projects/:id', requireAuth, async (c) => { @@ -256,9 +256,10 @@ app.put('/api/projects/:id', requireAuth, async (c) => { } const tasks = Array.isArray(body.tasks) ? body.tasks : JSON.parse(p.tasks_json); const version = p.version + 1; + const methodology = ['waterfall', 'agile', 'hybrid'].includes(body.methodology) ? body.methodology : (p.methodology || 'waterfall'); db.prepare( - "UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, updated_at=datetime('now') WHERE id=?" - ).run(body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), version, id); + "UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, methodology=?, updated_at=datetime('now') WHERE id=?" + ).run(body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), version, methodology, id); logAudit(p.org_id, uid, 'project.update', 'project', id, { version, tasks: tasks.length }); // Revision history: snapshot every save, keep the last 20 per project. try { @@ -1201,6 +1202,43 @@ app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { return c.json({ id: nid, name: newName, version: 1 }); }); +// -------------------------------------------------------------- sprints +// Agile/Hybrid: 시간상자(스프린트) CRUD. 작업은 task.sprint(이름)로 배정되고 +// task.storyPoints로 추정된다 — 지표(커밋/완료 포인트, 벨로시티)는 클라이언트 +// 순수 함수(computeSprintStats)가 계산한다. +app.post('/api/projects/:id/sprints', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { name, startDate, endDate, goal } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + const day = (v) => (/^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) ? v : ''); + const sid = rowid(db.prepare('INSERT INTO sprints(project_id,name,start_date,end_date,goal) VALUES(?,?,?,?,?)') + .run(p.id, String(name).trim().slice(0, 80), day(startDate), day(endDate), String(goal || '').slice(0, 300))); + logAudit(p.org_id, uid, 'sprint.create', 'project', p.id, { sprintId: sid, name }); + return c.json({ id: sid, name: String(name).trim() }); +}); + +app.get('/api/projects/:id/sprints', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const sprints = db.prepare( + 'SELECT id, name, start_date AS startDate, end_date AS endDate, goal FROM sprints WHERE project_id = ? ORDER BY start_date, id' + ).all(p.id); + return c.json({ sprints, methodology: p.methodology || 'waterfall' }); +}); + +app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM sprints WHERE id = ? AND project_id = ?').run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + // ------------------------------------------------------------- baselines // Snapshot a project's current plan as a named baseline (schedule-control: // compare actuals against the frozen plan later). diff --git a/server/db.mjs b/server/db.mjs index 72a5690f..122b70d6 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS projects ( tasks_json TEXT NOT NULL DEFAULT '[]', version INTEGER NOT NULL DEFAULT 1, archived INTEGER NOT NULL DEFAULT 0, + methodology TEXT NOT NULL DEFAULT 'waterfall', created_by INTEGER NOT NULL REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) @@ -128,6 +129,16 @@ CREATE TABLE IF NOT EXISTS comments ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_comments_project ON comments(project_id, id); +CREATE TABLE IF NOT EXISTS sprints ( + id INTEGER PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + start_date TEXT NOT NULL DEFAULT '', + end_date TEXT NOT NULL DEFAULT '', + goal TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_sprints_project ON sprints(project_id, id); CREATE TABLE IF NOT EXISTS attachments ( id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, @@ -164,6 +175,7 @@ CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token); // Migration for pre-existing DBs: add token_version if missing (idempotent). try { db.exec('ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } try { db.exec('ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } +try { db.exec("ALTER TABLE projects ADD COLUMN methodology TEXT NOT NULL DEFAULT 'waterfall'"); } catch { /* already there */ } // node:sqlite returns lastInsertRowid as number|bigint; normalize to Number. export const rowid = (r) => Number(r.lastInsertRowid); diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index 3ba0905d..f92622f5 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -547,6 +547,30 @@ assert.equal(portAfter.status, 'delay', 'SPI 0.3 → delay'); r = await req(`/api/orgs/${orgAId}/portfolio`, { headers: oauth }); assert.equal(r.status, 404, 'non-member portfolio → 404'); +// ---- 스프린트 + 방법론 (Agile/Hybrid) ---- +r = await req(`/api/projects/${proj.id}/sprints`, { method: 'POST', headers: auth, body: body({ name: 'Sprint 1', startDate: '2026-06-01', endDate: '2026-06-14' }) }); +assert.equal(r.status, 200, 'sprint create'); +const sp1 = await r.json(); +r = await req(`/api/projects/${proj.id}/sprints`, { headers: auth }); +let spData = await r.json(); +assert.ok(spData.sprints.some((x) => x.id === sp1.id && x.startDate === '2026-06-01'), 'sprint listed'); +assert.equal(spData.methodology, 'waterfall', 'default methodology'); +// 방법론 변경(hybrid) → GET 반영 +let mv = (await (await req(`/api/projects/${proj.id}`, { headers: auth })).json()).version; +r = await req(`/api/projects/${proj.id}`, { method: 'PUT', headers: auth, body: body({ methodology: 'hybrid', version: mv }) }); +assert.equal(r.status, 200); +r = await req(`/api/projects/${proj.id}`, { headers: auth }); +assert.equal((await r.json()).methodology, 'hybrid', 'methodology persisted'); +// 불량 값은 무시(기존 유지) +mv = (await (await req(`/api/projects/${proj.id}`, { headers: auth })).json()).version; +await req(`/api/projects/${proj.id}`, { method: 'PUT', headers: auth, body: body({ methodology: 'chaos', version: mv }) }); +assert.equal((await (await req(`/api/projects/${proj.id}`, { headers: auth })).json()).methodology, 'hybrid', 'invalid methodology ignored'); +// 가드 + 삭제 +r = await req(`/api/projects/${proj.id}/sprints`, { method: 'POST', headers: oauth, body: body({ name: 'X' }) }); +assert.equal(r.status, 404, 'non-member sprint → 404'); +r = await req(`/api/projects/${proj.id}/sprints/${sp1.id}`, { method: 'DELETE', headers: auth }); +assert.equal(r.status, 200, 'sprint delete'); + // ---- AI 브리핑 (orchestrator mock) ---- r = await req(`/api/projects/${proj.id}/ai/brief`, { method: 'POST', headers: auth }); assert.equal(r.status, 200, 'ai brief 200'); diff --git a/tests/unit/burndown.test.mjs b/tests/unit/burndown.test.mjs new file mode 100644 index 00000000..dffa53df --- /dev/null +++ b/tests/unit/burndown.test.mjs @@ -0,0 +1,28 @@ +// 번다운 (순수 로직 in cloud-sync.js). +import assert from 'node:assert'; +import { computeBurndown } from '../../cloud-sync.js'; + +const sprint = { name: 'S1', startDate: '2026-07-01', endDate: '2026-07-05' }; // 5일 +const tasks = [ + { id: 'a', sprint: 'S1', storyPoints: 5, actualProgress: 100, actualEndDate: '2026-07-02' }, + { id: 'b', sprint: 'S1', storyPoints: 3, actualProgress: 100, actualEndDate: '2026-07-04' }, + { id: 'c', sprint: 'S1', storyPoints: 2, actualProgress: 50 }, + { id: 'x', sprint: 'S2', storyPoints: 99 }, // 타 스프린트 제외 + { id: 's', sprint: 'S1', storyPoints: 7, isSynthetic: true }, // 합성 제외 +]; + +const bd = computeBurndown(tasks, sprint, '2026-07-04'); +assert.equal(bd.days.length, 5); +assert.equal(bd.committed, 10, '5+3+2'); +assert.equal(bd.ideal[0], 10); +assert.equal(bd.ideal[4], 0, '이상선은 0으로 소진'); +assert.deepEqual(bd.actual, [10, 5, 5, 2, null], 'D1 잔여10 → D2 a완료(5) → D4 b완료(2) → 미래 null'); + +// 완료일 없는 100% 작업은 today 완료로 간주 +const bd2 = computeBurndown([{ id: 'a', sprint: 'S1', storyPoints: 4, actualProgress: 100 }], sprint, '2026-07-03'); +assert.deepEqual(bd2.actual.slice(0, 3), [4, 4, 0], 'today(D3)에 소진 반영'); + +assert.equal(computeBurndown(tasks, { name: 'S1' }, '2026-07-04'), null, '기간 없으면 null'); +assert.equal(computeBurndown([], sprint, '2026-07-04'), null, '포인트 0이면 null'); + +console.log('✓ burndown tests passed'); diff --git a/tests/unit/sprint-stats.test.mjs b/tests/unit/sprint-stats.test.mjs new file mode 100644 index 00000000..d550ef82 --- /dev/null +++ b/tests/unit/sprint-stats.test.mjs @@ -0,0 +1,30 @@ +// Agile 스프린트 지표 (순수 로직 in cloud-sync.js). +import assert from 'node:assert'; +import { computeSprintStats } from '../../cloud-sync.js'; + +const sprints = [ + { id: 1, name: 'Sprint 1', startDate: '2026-06-01', endDate: '2026-06-14' }, // 종료 + { id: 2, name: 'Sprint 2', startDate: '2026-06-15', endDate: '2026-06-28' }, // 종료 + { id: 3, name: 'Sprint 3', startDate: '2026-07-06', endDate: '2026-07-19' }, // 진행 +]; +const tasks = [ + { id: 'a', sprint: 'Sprint 1', storyPoints: 5, actualProgress: 100 }, + { id: 'b', sprint: 'Sprint 1', storyPoints: 3, actualProgress: 100 }, + { id: 'c', sprint: 'Sprint 2', storyPoints: 8, actualProgress: 100 }, + { id: 'd', sprint: 'Sprint 2', storyPoints: 5, actualProgress: 50 }, // 미완 → 벨로시티 제외 + { id: 'e', sprint: 'Sprint 3', storyPoints: 13, actualProgress: 20 }, + { id: 'f', storyPoints: 8 }, // 백로그(스프린트 없음) + { id: 'g', sprint: '없는스프린트', storyPoints: 2 }, // 백로그(미존재 스프린트) + { id: 's', sprint: 'Sprint 3', storyPoints: 99, isSynthetic: true }, // 합성행 제외 +]; + +const st = computeSprintStats(tasks, sprints, '2026-07-07'); +const [s1, s2, s3] = st.rows; +assert.equal(s1.committed, 8); assert.equal(s1.completed, 8); assert.ok(s1.closed); +assert.equal(s2.committed, 13); assert.equal(s2.completed, 8); assert.equal(s2.remaining, 5); +assert.equal(s3.committed, 13); assert.equal(s3.completed, 0); assert.ok(!s3.closed, '진행 중'); +assert.equal(st.velocity, 8, '(8+8)/2 종료 스프린트 평균'); +assert.equal(st.backlogCount, 2, '미배정 + 미존재 스프린트'); +assert.equal(computeSprintStats([], [], '2026-07-07').velocity, null, '종료 스프린트 없음 → N/A'); + +console.log('✓ sprint-stats tests passed');