diff --git a/.github/workflows/visual-accessibility-evidence.yml b/.github/workflows/visual-accessibility-evidence.yml new file mode 100644 index 00000000..dee9c225 --- /dev/null +++ b/.github/workflows/visual-accessibility-evidence.yml @@ -0,0 +1,54 @@ +name: Visual Accessibility Evidence + +on: + pull_request: + push: + branches: [develop] + +permissions: + contents: read + +concurrency: + group: visual-accessibility-evidence-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + browser-evidence: + runs-on: ubuntu-latest + steps: + - name: Checkout exact revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Verify exact checkout + env: + EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + actual_sha="$(git rev-parse HEAD)" + test "$actual_sha" = "$EXPECTED_CHECKOUT_SHA" + + - name: Setup Node 22.13 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22.13.0 + + - name: Install + run: npm ci + + - name: Install Playwright (chromium) + run: npx playwright install chromium --with-deps + + - name: Capture visual and accessibility evidence + run: npm run test:e2e -- tests/e2e/visual-accessibility-evidence.spec.js + + - name: Preserve visual accessibility evidence + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scopeweave-visual-accessibility-${{ github.run_id }}-${{ github.run_attempt }} + path: test-results + if-no-files-found: error + retention-days: 3 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4688d27b..f21c8dca 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -8,7 +8,7 @@ - `toast-state.css`: cloud overlay `.toast.visible` rendering so SaaS status messages stay visually observable. - `app.js`: state, rendering, editing, validation, persistence, - import/export, and Gantt logic. + import/export, WBS filtering, and Gantt logic. - `analytics.js`: EVM, S-curve, CPM, workload, cost, and requirements/RFI/RFP WBS-estimation readiness analysis. - `wbs.json`: seed data in the user-specified JSON array format. @@ -35,6 +35,9 @@ optional File System Access API sync for `wbs.json` where supported. - Static hosting treats repository `wbs.json` as seed data; export/manual save remains the portability path. +- A first seed-only visit exposes an accessible onboarding notice; its dismissal + marker is separate from the planner payload, while confirmed clearing persists + an empty `tasks` array through the normal `renderAll()` and autosave path. - Imported flat JSON may synthesize hierarchy wrapper nodes internally, but external `wbs.json` sync strips synthetic rows so the saved array stays in the requested user schema. diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..52e26ec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added accessible WBS field search with hierarchy context and an empty-result + recovery action. +- Added planning-field search coverage and safe state reset when Cloud or file + imports replace the current plan. +- Kept first-visit sample guidance visible when local persistence fails. +- Added browser JSON download for portable WBS backups, including extended + planning fields, alongside CSV export. +- Added first-visit sample WBS onboarding with persistent dismissal and a + confirmed clear-to-empty-plan action. +- Added search-mode guardrails that keep hierarchy edits and drag reordering + out of the filtered view while an inline editor is open. - Added deterministic PM analysis for requirements/RFI/RFP readiness, WBS estimation coverage, dependency risk, and procurement package section checks. - Preserved PM-analysis research papers, NASA WBS handbook, BCP 14, and JSON @@ -19,6 +30,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added workflow ownership regression coverage so central review workflows stay inherited from `ContextualWisdomLab/.github`, not copied into this repository. +- Added exact-head Node/Chromium coverage collection and report merging; the + remaining uncovered paths still block a 100% frontend/backend claim. +- Made the canonical coverage run execute the complete unit and API suites so + existing pure-logic and server regression evidence is included in the merged report. +- Added browser regression coverage for empty-plan actions, unsupported file sync, + filtered hierarchy protection, collapse/expand, and oversized CSV rejection. +- Added browser coverage for cloud sharing, sprint burndown, attachments, + search, team administration, project creation, and sample onboarding flows. ### Security @@ -30,6 +49,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Replaced dynamic and lazy-regex MS Project XML block extraction with bounded linear scans to prevent pathological backtracking on malformed imports. - Rejected non-string password candidates at the authentication boundary. +- Fixed cloud login and team modal close controls when their nested icon is + clicked. - Added regression coverage that prevents array-valued passwords from being coerced into valid credentials. - Updated Hono runtime dependencies to patched supported releases. diff --git a/README.md b/README.md index 6340c1f4..61c7fbb7 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ two modes: subtree reorder - Automatic day, weight, planned progress, actual progress, and weighted progress calculations -- CSV import/export using the screen column contract +- WBS search across task fields with matching hierarchy context +- First-visit sample WBS guidance with dismissible notice and clear-to-empty-plan action +- JSON/CSV export and CSV import using the screen column contract - Local autosave with optional File System Access API sync to `wbs.json` - Weekly Gantt modal with planned (`#333333`) and actual (`#34cb03`) overlays - Responsive column reduction for screens under 800px diff --git a/app.js b/app.js index a04aae71..8b8dcf07 100644 --- a/app.js +++ b/app.js @@ -1,4 +1,5 @@ const STORAGE_KEY = 'scopeweave:planner-state:v1'; +const ONBOARDING_DISMISSED_KEY = 'scopeweave:onboarding-dismissed:v1'; const DEFAULT_PROJECT_NAME = 'ScopeWeave Planner'; const MAX_PROJECT_NAME_LENGTH = 120; const MAX_BASE_DATE_LENGTH = 10; @@ -178,6 +179,8 @@ const state = { projectName: DEFAULT_PROJECT_NAME, baseDate: formatLocalDateInput(new Date()), tasks: [], + taskQuery: '', + showSeedOnboarding: false, editor: { ...DEFAULT_EDITOR_STATE, errors: [] }, jsonSyncHandle: null, dragTaskId: null, @@ -214,8 +217,12 @@ const elements = { plannedProgress: document.getElementById('summary-planned-progress'), actualProgress: document.getElementById('summary-actual-progress'), tableBody: document.getElementById('task-table-body'), + seedOnboarding: document.getElementById('seed-onboarding'), + dismissSeedOnboardingButton: document.getElementById('dismiss-seed-onboarding'), + clearSeedDataButton: document.getElementById('clear-seed-data'), addRootButton: document.getElementById('add-root-task'), exportCsvButton: document.getElementById('export-csv'), + exportJsonButton: document.getElementById('export-json'), importCsvButton: document.getElementById('import-csv'), csvFileInput: document.getElementById('csv-file-input'), ganttModal: document.getElementById('gantt-modal'), @@ -224,6 +231,9 @@ const elements = { closeGanttButton: document.getElementById('close-gantt'), connectJsonSyncButton: document.getElementById('connect-json-sync'), syncStatus: document.getElementById('sync-status'), + taskFilterInput: document.getElementById('task-filter'), + clearTaskFilterButton: document.getElementById('clear-task-filter'), + taskFilterStatus: document.getElementById('task-filter-status'), toast: document.getElementById('toast') }; @@ -245,16 +255,19 @@ async function bootstrap() { const cloudState = cloudApi ? await cloudApi.boot() : null; if (cloudState) { + state.showSeedOnboarding = false; hydrateState(cloudState); persistState({ syncCloud: false }); } else { const savedState = loadLocalState(); if (savedState) { + state.showSeedOnboarding = false; hydrateState(savedState); persistState(); } else { const seedData = await loadSeedTasks(); state.tasks = normalizeImportedTasks(seedData); + state.showSeedOnboarding = state.tasks.length > 0 && !isSeedOnboardingDismissed(); invalidateTaskIndexCache(); } } @@ -302,7 +315,14 @@ function bindHeaderEvents(persistAndRenderMetadata) { }); elements.baseDateInput.addEventListener('blur', persistAndRenderMetadata.flush); - elements.addRootButton.addEventListener('click', () => openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() })); + elements.addRootButton.addEventListener('click', (event) => { + if (elements.addRootButton.getAttribute('aria-disabled') === 'true') { + event.preventDefault(); + showToast('검색 중에는 작업을 추가할 수 없습니다. 검색을 먼저 지워주세요.'); + return; + } + openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() }); + }); elements.exportCsvButton.addEventListener('click', (e) => { if (elements.exportCsvButton.getAttribute('aria-disabled') === 'true') { e.preventDefault(); @@ -311,6 +331,14 @@ function bindHeaderEvents(persistAndRenderMetadata) { } exportCsv(); }); + elements.exportJsonButton.addEventListener('click', (e) => { + if (elements.exportJsonButton.getAttribute('aria-disabled') === 'true') { + e.preventDefault(); + showToast('내보낼 작업이 없습니다. 하단의 버튼을 통해 작업을 추가해주세요.'); + return; + } + exportJson(); + }); elements.importCsvButton.addEventListener('click', () => elements.csvFileInput.click()); elements.csvFileInput.addEventListener('change', handleCsvImport); elements.openGanttButton.addEventListener('click', (e) => { @@ -336,6 +364,21 @@ function bindHeaderEvents(persistAndRenderMetadata) { } await connectJsonSync(); }); + + elements.taskFilterInput.addEventListener('input', (event) => { + if (state.editor.mode) { + return; + } + state.taskQuery = String(event.target.value).slice(0, 120); + renderAll(); + }); + elements.clearTaskFilterButton.addEventListener('click', () => { + state.taskQuery = ''; + renderAll(); + elements.taskFilterInput.focus(); + }); + elements.dismissSeedOnboardingButton.addEventListener('click', dismissSeedOnboarding); + elements.clearSeedDataButton.addEventListener('click', clearSeedData); } function bindModalEvents() { @@ -434,6 +477,10 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { }); elements.tableBody.addEventListener('dragstart', (event) => { + if (state.taskQuery.trim()) { + event.preventDefault(); + return; + } const row = event.target.closest('tr[data-task-id]'); if (!row) { return; @@ -453,6 +500,9 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { }); elements.tableBody.addEventListener('dragover', (event) => { + if (state.taskQuery.trim()) { + return; + } const row = event.target.closest('tr[data-task-id]'); if (!row || !state.dragTaskId || row.dataset.taskId === state.dragTaskId) { return; @@ -482,6 +532,11 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { }); elements.tableBody.addEventListener('drop', (event) => { + if (state.taskQuery.trim()) { + event.preventDefault(); + clearDragState(); + return; + } const row = event.target.closest('tr[data-task-id]'); if (!row || !state.dragTaskId) { return; @@ -505,6 +560,8 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) { const cachedHasChildrenSet = new Set(); function renderAll() { const metrics = computeTaskMetrics(); + const filterActive = Boolean(state.taskQuery.trim()); + const editorOpen = Boolean(state.editor.mode); elements.projectNameInput.value = state.projectName; document.title = state.projectName === DEFAULT_PROJECT_NAME ? DEFAULT_PROJECT_NAME : `${state.projectName} - ${DEFAULT_PROJECT_NAME}`; @@ -513,6 +570,17 @@ function renderAll() { elements.plannedProgress.textContent = formatPercent(metrics.totalWeightedPlannedRatio * 100, 2); elements.actualProgress.textContent = formatPercent(metrics.totalWeightedActualRatio * 100, 2); elements.syncStatus.textContent = state.jsonSyncHandle ? '연결된 wbs.json 파일에 자동저장 중' : '브라우저 로컬 자동저장 사용 중'; + if (elements.taskFilterInput.value !== state.taskQuery) { + elements.taskFilterInput.value = state.taskQuery; + } + elements.taskFilterInput.disabled = editorOpen; + if (editorOpen) { + elements.taskFilterInput.setAttribute('aria-disabled', 'true'); + elements.taskFilterInput.title = '편집을 완료하거나 취소한 후 검색할 수 있습니다.'; + } else { + elements.taskFilterInput.removeAttribute('aria-disabled'); + elements.taskFilterInput.removeAttribute('title'); + } if (typeof window !== 'undefined') { window.ScopeWeaveAnalytics?.render?.({ @@ -528,17 +596,27 @@ function renderAll() { const visibleTasks = getVisibleTasks(); const rows = []; + elements.seedOnboarding.hidden = !state.showSeedOnboarding || filterActive; + elements.clearTaskFilterButton.hidden = !filterActive; + elements.taskFilterStatus.textContent = filterActive + ? `${visibleTasks.length}개 작업 표시 중 (전체 ${state.tasks.length}개)` + : `전체 ${state.tasks.length}개 작업`; const hasTasks = state.tasks.length > 0; if (!hasTasks) { elements.exportCsvButton.setAttribute('aria-disabled', 'true'); + elements.exportJsonButton.setAttribute('aria-disabled', 'true'); elements.openGanttButton.setAttribute('aria-disabled', 'true'); } else { elements.exportCsvButton.removeAttribute('aria-disabled'); + elements.exportJsonButton.removeAttribute('aria-disabled'); elements.openGanttButton.removeAttribute('aria-disabled'); } elements.exportCsvButton.title = hasTasks ? '' : '내보낼 작업이 없습니다. 하단의 버튼을 통해 작업을 추가해주세요.'; + elements.exportJsonButton.title = elements.exportCsvButton.title; elements.openGanttButton.title = hasTasks ? '' : '간트 차트로 표시할 작업이 없습니다. 작업을 먼저 추가해주세요.'; + elements.addRootButton.setAttribute('aria-disabled', String(filterActive)); + elements.addRootButton.title = filterActive ? '검색 중에는 작업을 추가할 수 없습니다. 검색을 먼저 지워주세요.' : ''; // ⚡ Bolt: Cache parent IDs to convert O(N^2) render loop to O(N) cachedHasChildrenSet.clear(); @@ -586,40 +664,47 @@ function createEmptyStateRow() { const icon = document.createElement('div'); icon.className = 'empty-icon'; icon.setAttribute('aria-hidden', 'true'); - icon.textContent = '📋'; + const filtered = Boolean(state.taskQuery.trim()); + icon.textContent = filtered ? '🔎' : '📋'; const title = document.createElement('h3'); title.className = 'empty-title'; - title.textContent = '등록된 작업이 없습니다'; + title.textContent = filtered ? '검색 결과가 없습니다' : '등록된 작업이 없습니다'; const description = document.createElement('p'); description.className = 'empty-desc'; - description.append( - "하단의 '최상위 작업 추가' 버튼을 눌러 프로젝트를 시작하거나,", - document.createElement('br'), - "'CSV 가져오기'를 통해 기존 데이터를 불러오세요." - ); + if (filtered) { + description.textContent = `‘${state.taskQuery}’에 일치하는 작업이 없습니다.`; + } else { + description.append( + "하단의 '최상위 작업 추가' 버튼을 눌러 프로젝트를 시작하거나,", + document.createElement('br'), + "'CSV 가져오기'를 통해 기존 데이터를 불러오세요." + ); + } const actions = document.createElement('div'); actions.className = 'empty-actions editor-actions'; - const addRootBtn = document.createElement('button'); - addRootBtn.type = 'button'; - addRootBtn.className = 'primary-button'; - addRootBtn.textContent = '최상위 작업 추가'; - addRootBtn.addEventListener('click', () => { - openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() }); - }); + if (!filtered) { + const addRootBtn = document.createElement('button'); + addRootBtn.type = 'button'; + addRootBtn.className = 'primary-button'; + addRootBtn.textContent = '최상위 작업 추가'; + addRootBtn.addEventListener('click', () => { + openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() }); + }); - const importCsvBtn = document.createElement('button'); - importCsvBtn.type = 'button'; - importCsvBtn.className = 'secondary-button'; - importCsvBtn.textContent = 'CSV 가져오기'; - importCsvBtn.addEventListener('click', () => { - document.getElementById('csv-file-input').click(); - }); + const importCsvBtn = document.createElement('button'); + importCsvBtn.type = 'button'; + importCsvBtn.className = 'secondary-button'; + importCsvBtn.textContent = 'CSV 가져오기'; + importCsvBtn.addEventListener('click', () => { + document.getElementById('csv-file-input').click(); + }); - actions.append(addRootBtn, importCsvBtn); + actions.append(addRootBtn, importCsvBtn); + } emptyState.append(icon, title, description, actions); cell.appendChild(emptyState); @@ -673,6 +758,8 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { const row = taskRowTemplate.cloneNode(false); row.className = `task-row depth-${task.depth} ${index % 2 === 1 ? 'striped-even' : ''}`; row.dataset.taskId = task.id; + const filterActive = Boolean(state.taskQuery.trim()); + row.draggable = !filterActive; const actionCell = actionCellTemplate.cloneNode(false); const actionStack = actionStackTemplate.cloneNode(false); @@ -681,12 +768,21 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { if (hasChildren) { const toggleButton = toggleButtonTemplate.cloneNode(false); - const toggleLabel = task.expanded ? '접기' : '펼치기'; + const searchExpanded = cachedSearchExpandedParentIds.has(task.id); + const expanded = searchExpanded || task.expanded; + const toggleLabel = searchExpanded + ? '검색 중 계층 맥락 고정' + : (filterActive ? '검색 중 비활성화' : (task.expanded ? '접기' : '펼치기')); toggleButton.setAttribute('aria-label', `${toggleLabel} - ${rowEntityName}`); - toggleButton.setAttribute('aria-expanded', String(task.expanded)); + toggleButton.setAttribute('aria-expanded', String(expanded)); toggleButton.title = `${toggleLabel} - ${rowEntityName}`; + if (filterActive || searchExpanded) { + toggleButton.setAttribute('aria-disabled', 'true'); + } else { + toggleButton.removeAttribute('aria-disabled'); + } const toggleIcon = toggleIconTemplate.cloneNode(false); - toggleIcon.textContent = task.expanded ? '▼' : '▶'; + toggleIcon.textContent = expanded ? '▼' : '▶'; toggleButton.appendChild(toggleIcon); actionStack.appendChild(toggleButton); } else { @@ -699,8 +795,11 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { const isLeaf = task.depth >= 3; const addChildButton = createActionButton(`하위 추가 - ${rowEntityName}`, '+', 'add-child', isLeaf ? '최대 3단계까지만 추가할 수 있습니다.' : `하위 추가 - ${rowEntityName}`); - if (isLeaf) { + if (isLeaf || filterActive) { addChildButton.setAttribute('aria-disabled', 'true'); + if (filterActive && !isLeaf) { + addChildButton.title = '검색 중에는 작업을 추가할 수 없습니다. 검색을 먼저 지워주세요.'; + } } else { addChildButton.removeAttribute('aria-disabled'); } @@ -708,7 +807,12 @@ function renderTaskRow(task, taskMetrics, index, hasChildren) { const editButton = createActionButton(`편집 - ${rowEntityName}`, '✎', 'edit', `편집 - ${rowEntityName}`); editButton.setAttribute('aria-haspopup', 'dialog'); - const deleteButton = createActionButton(`삭제 - ${rowEntityName}`, '🗑', 'delete', `삭제 - ${rowEntityName}`); + const deleteButton = createActionButton(`삭제 - ${rowEntityName}`, '🗑', 'delete', filterActive + ? '검색 중에는 작업을 삭제할 수 없습니다. 검색을 먼저 지워주세요.' + : `삭제 - ${rowEntityName}`); + if (filterActive) { + deleteButton.setAttribute('aria-disabled', 'true'); + } actionStack.append( dragHandle, @@ -1113,6 +1217,11 @@ function handleRowAction(action, taskId) { return; } + if (state.taskQuery.trim() && (action === 'toggle' || action === 'add-child' || action === 'delete')) { + showToast('검색 중에는 계층을 변경할 수 없습니다. 검색을 먼저 지워주세요.'); + return; + } + if (action === 'toggle') { task.expanded = !task.expanded; persistState(); @@ -1176,6 +1285,10 @@ function handleRowAction(action, taskId) { } function openEditor({ mode, targetId = null, parentId = null, depth = 1, insertAfterId = null, draft = null }) { + if (mode === 'create' && state.taskQuery.trim()) { + showToast('검색 중에는 작업을 추가할 수 없습니다. 검색을 먼저 지워주세요.'); + return; + } state.previousFocus = document.activeElement; if (mode === 'edit') { const task = findTask(targetId); @@ -1496,10 +1609,43 @@ function getDateRangeWarning(startDate, endDate, message) { } const cachedHiddenParentIds = new Set(); +const cachedSearchExpandedParentIds = new Set(); +const TASK_SEARCH_FIELDS = [ + 'name', 'phase', 'activity', 'task', 'categoryLarge', 'categoryMedium', 'documentName', + 'owner', 'supportTeam', 'actualProgressStatus', 'plannedStartDate', + 'plannedEndDate', 'actualStartDate', 'actualEndDate', 'predecessors', 'budget', + 'actualCost', 'sprint', 'storyPoints' +]; + +function taskSearchText(task) { + return TASK_SEARCH_FIELDS.map((field) => String(task[field] ?? '')).join(' ').toLowerCase(); +} function getVisibleTasks() { const visible = []; cachedHiddenParentIds.clear(); + cachedSearchExpandedParentIds.clear(); + + const query = state.taskQuery.trim().toLowerCase(); + if (query) { + const tasksById = new Map(state.tasks.map((task) => [task.id, task])); + const matchingIds = new Set(); + state.tasks.forEach((task) => { + if (taskSearchText(task).includes(query)) { + let current = task; + const visitedIds = new Set(); + while (current && !visitedIds.has(current.id)) { + visitedIds.add(current.id); + matchingIds.add(current.id); + if (current.id !== task.id) { + cachedSearchExpandedParentIds.add(current.id); + } + current = tasksById.get(current.parentId); + } + } + }); + return state.tasks.filter((task) => matchingIds.has(task.id)); + } // ⚡ Bolt Optimization: Single-pass O(N) visible task filtering to avoid redundant O(N * Depth) tree traversals state.tasks.forEach((task) => { @@ -1645,9 +1791,11 @@ function persistState({ syncCloud = true } = {}) { }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + state.showSeedOnboarding = false; } catch (error) { console.error('State persistence failed:', error); showToast('로컬 스토리지 용량이 초과되어 저장하지 못했습니다.'); + return false; } if (state.jsonSyncHandle) { @@ -1659,6 +1807,46 @@ function persistState({ syncCloud = true } = {}) { if (syncCloud && typeof window !== 'undefined') { window.ScopeWeaveCloud?.push?.(payload); } + return true; +} + +function isSeedOnboardingDismissed() { + try { + return localStorage.getItem(ONBOARDING_DISMISSED_KEY) === 'true'; + } catch { + return false; + } +} + +function dismissSeedOnboarding() { + try { + localStorage.setItem(ONBOARDING_DISMISSED_KEY, 'true'); + } catch { + // The notice is still dismissible for the current session if storage is unavailable. + } + state.showSeedOnboarding = false; + renderAll(); +} + +function clearSeedData() { + if (!state.showSeedOnboarding || !window.confirm('샘플 데이터를 지우고 빈 계획으로 시작하시겠습니까?')) { + return; + } + const previousTasks = state.tasks; + const previousOnboarding = state.showSeedOnboarding; + state.tasks = []; + state.showSeedOnboarding = false; + invalidateTaskIndexCache(); + if (!persistState()) { + state.tasks = previousTasks; + state.showSeedOnboarding = previousOnboarding; + invalidateTaskIndexCache(); + renderAll(); + return; + } + renderAll(); + showToast('샘플 데이터를 삭제했습니다. 첫 단계를 추가해 계획을 시작하세요.'); + requestAnimationFrame(() => elements.addRootButton.focus()); } function loadLocalState() { @@ -1676,6 +1864,8 @@ function hydrateState(savedState) { state.tasks = Array.isArray(savedState.tasks) ? savedState.tasks.filter(isTaskRecord).map(normalizeStoredTask) : []; + state.taskQuery = ''; + state.showSeedOnboarding = false; invalidateTaskIndexCache(); } @@ -1975,6 +2165,11 @@ function exportCsv() { downloadFile(csvText, `wbs_export_${formatCompactDate(new Date())}.csv`, 'text/csv;charset=utf-8'); } +function exportJson() { + const jsonText = JSON.stringify(exportJsonArray({ includeExtendedFields: true }), null, 2); + downloadFile(jsonText, `wbs_export_${formatCompactDate(new Date())}.json`, 'application/json;charset=utf-8'); +} + async function handleCsvImport(event) { const [file] = event.target.files || []; if (!file) { @@ -1997,8 +2192,8 @@ async function handleCsvImport(event) { try { const text = await file.text(); const imported = parseCsv(text); - state.tasks = validateImportedTasks(normalizeImportedTasks(imported)); - invalidateTaskIndexCache(); + const importedTasks = validateImportedTasks(normalizeImportedTasks(imported)); + hydrateState({ projectName: state.projectName, baseDate: state.baseDate, tasks: importedTasks }); closeEditor(true); persistState(); renderAll(); @@ -2191,23 +2386,38 @@ async function writeJsonSyncFile() { await writable.close(); } -function exportJsonArray() { - return state.tasks.filter((task) => !task.isSynthetic).map((task) => ({ - phase: task.phase, - activity: task.activity, - task: task.task, - categoryLarge: task.categoryLarge, - categoryMedium: task.categoryMedium, - documentName: task.documentName, - owner: task.owner, - supportTeam: task.supportTeam, - plannedStartDate: task.plannedStartDate, - plannedEndDate: task.plannedEndDate, - [LEGACY_PLANNED_END_FIELD]: task.plannedEndDate, - actualProgressStatus: task.actualProgressStatus, - actualStartDate: task.actualStartDate, - actualEndDate: task.actualEndDate - })); +function exportJsonArray({ includeExtendedFields = false } = {}) { + return state.tasks.filter((task) => !task.isSynthetic).map((task) => { + const record = { + phase: task.phase, + activity: task.activity, + task: task.task, + categoryLarge: task.categoryLarge, + categoryMedium: task.categoryMedium, + documentName: task.documentName, + owner: task.owner, + supportTeam: task.supportTeam, + plannedStartDate: task.plannedStartDate, + plannedEndDate: task.plannedEndDate, + [LEGACY_PLANNED_END_FIELD]: task.plannedEndDate, + actualProgressStatus: task.actualProgressStatus, + actualStartDate: task.actualStartDate, + actualEndDate: task.actualEndDate + }; + if (includeExtendedFields) { + Object.assign(record, { + name: task.name, + plannedProgress: task.plannedProgress, + actualProgress: task.actualProgress, + predecessors: task.predecessors ?? '', + budget: task.budget ?? '', + actualCost: task.actualCost ?? '', + sprint: task.sprint ?? '', + storyPoints: task.storyPoints ?? '' + }); + } + return record; + }); } function openGanttModal() { diff --git a/cloud-sync.js b/cloud-sync.js index 0e015ebe..be0924e6 100644 --- a/cloud-sync.js +++ b/cloud-sync.js @@ -203,7 +203,7 @@ function ensureAuthUI() { }; $('#cloud-toggle').addEventListener('click', () => setMode(mode === 'login' ? 'signup' : 'login')); $('#cloud-sso').addEventListener('click', () => { window.location.href = '/api/auth/oidc/start'; }); - modal.addEventListener('click', (e) => { if (e.target.dataset.cloudClose) modal.classList.add('hidden'); }); + modal.addEventListener('click', (e) => { if (e.target.closest?.('[data-cloud-close]')) modal.classList.add('hidden'); }); $('#cloud-form').addEventListener('submit', async (e) => { e.preventDefault(); const email = $('#cloud-email').value.trim(); @@ -1748,7 +1748,7 @@ async function openTeamModal() {
`; document.body.appendChild(modal); - modal.addEventListener('click', (e) => { if (e.target.dataset.teamClose) modal.classList.add('hidden'); }); + modal.addEventListener('click', (e) => { if (e.target.closest?.('[data-team-close]')) modal.classList.add('hidden'); }); modal.querySelector('#team-invite').addEventListener('submit', async (e) => { e.preventDefault(); const email = modal.querySelector('#team-email').value.trim(); diff --git a/docs/doctoring/coverage-evidence.md b/docs/doctoring/coverage-evidence.md new file mode 100644 index 00000000..ed398a73 --- /dev/null +++ b/docs/doctoring/coverage-evidence.md @@ -0,0 +1,53 @@ +# Coverage evidence and 100% readiness + +## Exact-head measurement + +On 2026-08-29, PR #632 source/test working head +`bce21b4eb53bf22ea454fd8bce0450b4f49bced5` +ran `BASE_URL=http://127.0.0.1:4174 npm run test:coverage` successfully with +a dedicated ScopeWeave static server. The merged Node/Chromium summary +contained 291 source entries: + +| Metric | Covered | Total | Result | +| --- | ---: | ---: | ---: | +| Lines/statements | 8,252 | 8,688 | 94.98% | +| Functions | 291 | 298 | 97.65% | +| Branches | 2,370 | 2,541 | 93.27% | + +The command's successful exit means the listed test cases completed and the +Node plus browser reports were merged; it does not mean the 100% quality +target was met. `node scripts/ci/check-coverage.mjs` fails with all four +thresholded metrics below 100%. + +## Scope boundary + +The Node phase uses c8 with `--all` for `app.js`, `cloud-sync.js`, +`analytics.js`, the CI helper, and every `server/*.mjs` module while executing +the complete unit and API suites. The browser +phase collects Chromium V8 JavaScript coverage during all 97 passing E2E +tests, converts the shipped client scripts, and merges both reports into one +Istanbul summary. Uncovered production lines remain in the report. + +## Required remediation + +1. Add tests for every remaining server branch and client error/empty-state + path, including the current `app.js` and `cloud-sync.js` uncovered regions. +2. Keep the merged exact-head report and make the strict command pass 100% + lines, statements, functions, and branches. + +Until those conditions are true, G-06 remains **측정됨, 진행 중** and no +release note may describe the repository as having 100% coverage. + +## References + +bcoe. (n.d.). *c8: Output coverage reports using Node.js' built-in coverage* +[Computer software]. GitHub. Retrieved August 29, 2026, from +https://github.com/bcoe/c8 + +Microsoft. (n.d.). *Coverage*. Playwright. Retrieved August 29, 2026, from +https://playwright.dev/docs/api/class-coverage + +## Rollback + +Remove this record, the G-06 baseline row, and its changelog entry together; +there is no runtime or persisted-data impact. diff --git a/docs/doctoring/visual-accessibility-evidence.md b/docs/doctoring/visual-accessibility-evidence.md new file mode 100644 index 00000000..7e7eed7f --- /dev/null +++ b/docs/doctoring/visual-accessibility-evidence.md @@ -0,0 +1,19 @@ +# Visual and accessibility evidence + +## Status + +The repository-local `Visual Accessibility Evidence` workflow captures real Chromium screenshots for the sample, skip-link focus, and empty-plan states on the exact pull-request head. It retains the artifact for three days as release evidence. + +## WCAG 2.2 baseline checks + +The browser test also verifies the skip link target, keyboard-focusable `main` landmark, labeled WBS search control, scoped table headers, and body foreground/background contrast at the WCAG 2.2 normal-text threshold of 4.5:1. These checks are intentionally limited to deterministic contracts; they do not claim a complete automated accessibility audit. + +No Storybook, Figma runtime, axe dependency, or application runtime dependency is needed for this static-hostable product. Design artifacts remain the rendered production page and its retained browser evidence. + +## Exact-head and artifact contract + +The workflow checks out `github.event.pull_request.head.sha`, verifies `git rev-parse HEAD`, uses no credential persistence, and uploads only the Playwright `test-results` evidence with a three-day retention limit. A failed browser test still retains any screenshots produced before the failure. + +## Rollback + +Rollback removes the workflow and its test. There is no persisted-data, API, authentication, or schema impact. diff --git a/docs/plans/2026-04-20-scopeweave-design.md b/docs/plans/2026-04-20-scopeweave-design.md index 62c4824d..ea58fec4 100644 --- a/docs/plans/2026-04-20-scopeweave-design.md +++ b/docs/plans/2026-04-20-scopeweave-design.md @@ -47,6 +47,10 @@ ## Persistence decision - `wbs.json` in the repository is treated as the initial seed and export format. - Every data mutation autosaves immediately to `localStorage`. +- On a first visit with only seed data, the UI labels the sample explicitly and + provides a dismissible notice or a confirmed clear-to-empty-plan action. +- The onboarding dismissal marker is stored separately from the planner payload + so the existing seed/autosave contract remains unchanged. - On browsers supporting File System Access API, the user can grant a writable handle for `wbs.json`; after that, each change also writes the JSON array to the chosen file automatically. - Where that API is unavailable, the app remains functional and exposes explicit JSON/CSV export paths; this is the safest achievable static-hosting behavior. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 00000000..60cdf744 --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,131 @@ +# ScopeWeave 제품·기술 Gap Baseline + +> 기준일: 2026-08-29 | 기준 브랜치: `develop` | 기준 HEAD: `2c328875e00e86537df3e965170be80532571cad` + +이 문서는 현재 저장소의 PRD, 기술 계약, 구현, 테스트, 운영 게이트를 한 +곳에서 추적하는 기준선이다. 문서의 상태는 의도나 열린 PR의 제목이 아니라 +현재 파일과 실행 증거를 기준으로 기록한다. + +## 1. 제품 목표와 구매자 + +주 구매자는 일정·공정 데이터를 WBS로 관리하고, 계획 대비 실적과 지연 +원인을 설명해야 하는 PM과 PMO다. 제품의 핵심 가치는 다음 세 가지다. + +1. `단계 > Activity > Task` 구조를 빠르게 편집한다. +2. 날짜·진척·선행작업에서 일정 통제 신호를 재현 가능하게 계산한다. +3. CSV와 브라우저 저장을 통해 별도 플랫폼에서도 계획을 회수한다. + +현재 범위는 정적 브라우저 클라이언트와 선택적 Node SaaS 계층이다. 정적 +호스팅에서 서버 파일을 덮어쓰지 않는다는 제약은 유지한다. + +## 2. PRD/TRD 추적성 + +| 요구 | 현재 구현 증거 | 상태 | +| --- | --- | --- | +| 3단계 WBS 편집·계층 보존 | `app.js`의 단일 `state.tasks`, `renderAll()`, expand/collapse·subtree 이동 | 완료 | +| 계획/실적 진척 및 일정 통제 | `analytics.js`의 EVM, S-curve, CPM, workload, PM readiness | 완료 | +| 계획을 찾고 계층 맥락을 유지 | `#task-filter`, `getVisibleTasks()`, `tests/e2e/scopeweave.spec.js` 검색 회귀 | 완료(이번 변경) | +| CSV 왕복 | `exportCsv()`, CSV parser/validation, E2E·fuzz 테스트 | 완료 | +| JSON seed·로컬 자동 저장 | `loadSeedTasks()`, `localStorage`, `exportJsonArray()`, JSON download | 완료 | +| 정적 배포 | `pages.yml`, 상대 경로 자산, `404.html` | 구현 완료, 실제 출판은 별도 런타임 증거 필요 | +| Cloud 인증·멀티테넌시·협업 | `server/`, `cloud-sync.js`, API smoke/E2E | 코드·테스트 존재, 운영 배포는 환경별 검증 필요 | + +## 3. UML 및 데이터 흐름 + +```mermaid +classDiagram + class ScopeWeaveState { + +string projectName + +string baseDate + +Task[] tasks + +string taskQuery + } + class Task { + +string id + +string parentId + +number depth + +string phase + +string activity + +string task + +string plannedStartDate + +string plannedEndDate + } + class AppController { + +bootstrap() + +renderAll() + +persistState() + } + class AnalyticsBridge { + +render(input) + +computeCpm(tasks) + +computeEvm(input) + } + class BrowserStorage { + +load() + +save(state) + } + ScopeWeaveState "1" *-- "0..*" Task + AppController --> ScopeWeaveState + AppController --> AnalyticsBridge + AppController --> BrowserStorage +``` + +`tasks`가 유일한 원천이며, 사용자 입력·파일 seed·Cloud snapshot은 이 +상태로 정규화된다. 화면 갱신은 `renderAll()` 하나를 통과하고, 분석은 +`window.ScopeWeaveAnalytics` 경계를 통해 선택적으로 호출된다. + +## 4. Gap 및 조치 상태 + +| ID | Gap / 고객 영향 | 조치 | 상태 | +| --- | --- | --- | --- | +| G-01 | 큰 WBS에서 작업 위치를 찾는 비용이 높았음 | 작업·담당자·산출물·예산·실투입비·스토리포인트 등 고객 필드를 검색하고 일치 행의 상위 계층을 함께 표시 | **완료** | +| G-02 | 정적 사용자가 JSON을 파일로 회수하려면 File System Access API에 의존 | 추가 계획 필드를 보존하는 브라우저 다운로드용 JSON export를 추가하고, 자동저장 seed 계약은 유지 | **완료** | +| G-03 | 첫 방문자가 seed 데이터와 실제 계획을 구분하기 어려움 | 첫 seed 방문에 샘플 WBS 안내를 표시하고, 안내 숨김과 확인 가능한 샘플 삭제 후 빈 계획 시작 경로를 제공 | **완료** | +| G-04 | 키보드·스크린리더 회귀는 E2E 일부로 보호되지만 시각 회귀 자동 검사는 없음 | `Visual Accessibility Evidence`가 exact-head Chromium에서 핵심 상태 PNG와 WCAG 2.2 기준선 검사를 실행하고 artifact를 3일 보존 | **완료** | +| G-05 | 보호 PR 큐는 소스와 무관한 Strix 공급자 429/Invalid URL 및 승인 부재로 차단될 수 있음 | 게이트를 약화하지 않고 원인 로그·artifact·현재 HEAD를 재검증한 뒤 재실행/중앙 수정 | 외부 상태 대기 | +| G-06 | 현재 합산 coverage가 저장소 전체 100%에 미달해 모든 클라이언트·서버 경로를 증명하지 못함 | Node c8과 Chromium V8 결과를 같은 exact-head 보고서로 합산하고 남은 경로를 보강한 뒤 100% threshold 통과 시에만 완료 처리 | **측정됨, 진행 중** | + +## 5. 품질·보안 기준선 + +- 런타임 의존성은 브라우저 native API와 현재 서버의 최소 의존성만 사용한다. +- `app.js`는 `new Function` 테스트 계약 때문에 top-level ESM import/export를 + 사용하지 않는다. +- 입력은 신뢰 경계에서 길이·날짜·CSV 수식·JSON prototype pollution을 + 검증하고, 동적 HTML 삽입 대신 `textContent`를 사용한다. +- 접근성은 레이블, landmark, keyboard focus, live status, disabled 상태, + reduced motion을 최소 기준으로 삼는다. +- 검증 명령은 `npm run test:unit`, `npm run test:api`, + `npm run test:e2e`, `python3 -m pytest tests/config`, `npm run fuzz`다. + `BASE_URL=http://127.0.0.1:4174 npm run test:coverage`의 2026-08-29 PR #632 + source/test working head `bce21b4eb53bf22ea454fd8bce0450b4f49bced5` 측정치는 전용 + ScopeWeave 정적 서버에서 Node·Chromium 결과를 합산한 lines/statements + 94.98%, functions 97.65%, branches 93.27%이며, + `node scripts/ci/check-coverage.mjs`는 아직 threshold 미달로 실패한다. + 이 결과는 `docs/doctoring/coverage-evidence.md`에 기록하고 100% 품질 + 기준의 미충족 증거로 취급한다. + G-01/G-02/G-03의 직접 증거는 검색·JSON·온보딩 회귀 테스트이며, G-04는 + `tests/e2e/visual-accessibility-evidence.spec.js`와 `Visual Accessibility Evidence` + artifact가 exact-head 브라우저 상태를 증명한다. + +## 6. 표준·연구 근거 + +- 국제표준화기구. (2020). *ISO 21502:2020: Project, programme and portfolio + management—Guidance on project management*. https://www.iso.org/standard/74947.html +- 국제표준화기구. (2018). *ISO 21511:2018: Work breakdown structures for + project and programme management*. https://www.iso.org/standard/69702.html + 현재 개정안 ISO/DIS 21511은 초안이므로 이 기준선의 normative 계약으로 + 사용하지 않는다. +- 국제표준화기구. (2026). *ISO 21508:2026: Project, programme and portfolio + management—Earned value management*. https://www.iso.org/standard/87899.html +- World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines + (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ +- Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software + development framework (SSDF) version 1.1: Recommendations for mitigating + the risk of software vulnerabilities* (NIST Special Publication 800-218). + National Institute of Standards and Technology. + https://doi.org/10.6028/NIST.SP.800-218 + +Repository-specific research and existing design decisions remain linked from +[`docs/plans/2026-04-20-scopeweave-design.md`](plans/2026-04-20-scopeweave-design.md), +[`docs/research/pm-analysis/README.md`](research/pm-analysis/README.md), and +[`ARCHITECTURE.md`](../ARCHITECTURE.md). diff --git a/docs/user-guide.md b/docs/user-guide.md index fb32af2f..ab5795ec 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -10,7 +10,39 @@ ScopeWeave Planner는 프로젝트용 WBS를 순수 HTML/CSS/JavaScript만으로 2. 하단 **최상위 작업 추가** 또는 각 행의 **+** 버튼으로 작업을 추가합니다. 3. 행 클릭 또는 **✎** 버튼으로 인라인 편집 모드를 엽니다. 4. 계획/실적 날짜와 실적진척상태를 입력하면 요약 수치와 가중치가 자동 재계산됩니다. -5. **CSV 내보내기** / **CSV 가져오기** / **간트차트보기**로 산출물을 활용합니다. +5. **JSON 내보내기** / **CSV 내보내기** / **CSV 가져오기** / **간트차트보기**로 + 산출물을 활용합니다. + +- **JSON 내보내기**는 선행작업, 예산, 실투입비, 스프린트, 스토리포인트를 + 포함한 휴대용 백업 파일(`wbs_export_YYYYMMDD.json`)을 다운로드합니다. + +## 첫 방문 샘플 + +- 저장된 계획이 없는 첫 방문에는 `wbs.json`의 예시 데이터를 구분할 수 있도록 + **샘플 WBS가 준비되어 있습니다** 안내가 표시됩니다. +- 예시를 둘러보려면 **안내 숨기기**를 선택합니다. 이 선택은 같은 브라우저에서 + 유지되며 계획 데이터와 별도로 저장됩니다. +- 실제 계획으로 시작하려면 **샘플 지우고 시작**을 선택하고 확인합니다. 샘플은 + 삭제되어 빈 계획으로 저장되고, 첫 단계를 추가할 수 있도록 최상위 작업 추가 + 버튼에 포커스가 이동합니다. +- 이미 로컬 또는 Cloud 계획이 저장된 브라우저에는 seed 안내가 다시 표시되지 + 않습니다. +- 프로젝트 이름이나 기준일을 먼저 바꾸어 저장하면 현재 seed가 사용자의 로컬 + 계획으로 채택된 것으로 간주되어 안내가 종료됩니다. 샘플을 둘러본 뒤 실제 + 계획을 시작할 때는 **샘플 지우고 시작**을 먼저 선택하세요. +- `wbs.json 자동저장 연결`은 샘플을 연결된 파일에 저장할 뿐이므로 안내를 + 종료하지 않습니다. 샘플을 계속 사용할지, 숨기거나 지울지 명시적으로 + 선택할 수 있습니다. + +## WBS 검색 + +- WBS 표 위의 **WBS 작업 검색**은 단계, 작업, 산출물, 담당자, 일정 등 입력된 + 필드를 검색합니다. +- 일치한 작업의 상위 계층은 함께 표시되어 검색 결과의 맥락을 유지합니다. +- 검색 중에는 계층 맥락을 유지하기 위해 상위 행의 접기 버튼이 비활성화됩니다. +- 검색 중에는 계층을 바꿀 수 없도록 드래그와 최상위/하위 작업 추가도 비활성화됩니다. +- 검색어는 브라우저 저장 데이터에 포함되지 않으며, **검색 지우기**로 전체 + 계층을 즉시 복원할 수 있습니다. ## 계층 규칙 @@ -32,6 +64,7 @@ ScopeWeave Planner는 프로젝트용 WBS를 순수 HTML/CSS/JavaScript만으로 - Chromium 계열 브라우저에서 **wbs.json 자동저장 연결** 버튼을 누르면 쓰기 가능한 `wbs.json` 파일을 연결할 수 있습니다. - 연결이 끝나면 변경할 때마다 같은 JSON 스키마로 자동 저장됩니다. - 외부 저장 JSON에는 내부 계층 관리용 synthetic row / internal id 필드가 포함되지 않습니다. +- 자동저장 연결은 seed 호환 스키마를 유지하며, 브라우저 다운로드는 추가 계획 필드까지 보존합니다. ## CSV 사용 diff --git a/index.html b/index.html index d24b2a88..73fe3281 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,8 @@