Skip to content
Closed
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
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@
## 2026-07-12 - Optimize renderTaskRow DOM allocations
**Learning:** Caching unattached template nodes and instantiating them via `.cloneNode(false)` reduces DOM instantiation overhead in O(N) render loops significantly.
**Action:** Apply this optimization to other hot-path rendering elements such as rows, cells, and stack containers.

## 2026-07-28 - Optimize table cell creation
**Learning:** Reusing `document.createElement()` dynamically for simple cells introduces heavy overhead during render. Caching unattached DOM element templates and cloning them using `cloneNode(false)` saves GC pressure and avoids unnecessary calls into the JS-to-C++ bridge.
**Action:** Always consider `cloneNode` caching for leaf DOM nodes (like badges, icons, inputs) inside O(N) loops.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.0.1] - 2026-06-25
### 성능 개선 (Performance)
- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다.

## [Unreleased]
### 성능 개선 (Performance)
- 테이블 렌더링 최적화: `app.js`에서 빈번하게 호출되는 DOM 요소 생성(`document.createElement()`) 비용을 줄이기 위해, `createTextCellContent`, `createOwnerCellContent`, `createStatusCellContent`, `createActualProgressCellContent` 함수 내부에서 초기 템플릿을 캐싱하고 `cloneNode(false)`를 활용하여 복제하도록 변경하여 O(N) 반복문 내의 성능을 개선했습니다.
Comment on lines +51 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

중복된 [Unreleased] 헤더를 제거하세요.

Line 8에 이미 동일한 헤더가 있으므로 Line 51의 헤더는 MD024 위반을 발생시킵니다. 성능 개선 항목을 기존 [Unreleased] 섹션 아래로 이동해 하나의 릴리스 섹션으로 병합하세요.

🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 51-51: Multiple headings with the same content

(MD024, no-duplicate-heading)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 51 - 53, Remove the duplicate [Unreleased] header
near the performance entry and move the existing 성능 개선 (Performance) subsection
under the earlier [Unreleased] section. Preserve the performance changelog entry
while leaving a single [Unreleased] header to satisfy MD024.

Source: Linters/SAST tools

50 changes: 39 additions & 11 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -898,17 +898,24 @@ function createTreeCellContent(value, depth) {
return treeValue;
}

let textCellWrapperTemplate = null;
let validationMessageTemplate = null;

function createTextCellContent(value, warning = '') {
if (!value) {
return warning ? createWarningBadge(warning) : createEmptyCell();
}
if (!warning) {
return document.createTextNode(value);
}
const wrapper = document.createElement('div');
if (!textCellWrapperTemplate) {
textCellWrapperTemplate = document.createElement('div');
validationMessageTemplate = document.createElement('div');
validationMessageTemplate.className = 'validation-message';
}
const wrapper = textCellWrapperTemplate.cloneNode(false);
wrapper.appendChild(document.createTextNode(value));
const validation = document.createElement('div');
validation.className = 'validation-message';
const validation = validationMessageTemplate.cloneNode(false);
validation.textContent = warning;
wrapper.appendChild(validation);
return wrapper;
Expand Down Expand Up @@ -951,6 +958,7 @@ function createWarningBadge(warning) {
}

const persistentOwnerColorMap = new Map();
let ownerBadgeTemplate = null;

function createOwnerCellContent(owner) {
if (!owner) {
Expand All @@ -961,18 +969,27 @@ function createOwnerCellContent(owner) {
persistentOwnerColorMap.set(owner, OWNER_COLORS[persistentOwnerColorMap.size % OWNER_COLORS.length]);
}

const badge = document.createElement('span');
badge.className = 'owner-badge';
if (!ownerBadgeTemplate) {
ownerBadgeTemplate = document.createElement('span');
ownerBadgeTemplate.className = 'owner-badge';
}

const badge = ownerBadgeTemplate.cloneNode(false);
badge.style.background = persistentOwnerColorMap.get(owner);
badge.textContent = owner;
return badge;
}

let statusBadgeTemplate = null;

function createStatusCellContent(progressState) {
if (!progressState.label) {
return createEmptyCell();
}
const badge = document.createElement('span');
if (!statusBadgeTemplate) {
statusBadgeTemplate = document.createElement('span');
}
const badge = statusBadgeTemplate.cloneNode(false);
badge.className = `status-badge ${progressState.className}`;
badge.textContent = progressState.label;
if (progressState.description) {
Expand All @@ -993,12 +1010,20 @@ function createMetricText(value, testId = '') {
return metric;
}

let actualProgressLabelTemplate = null;
let actualProgressSrOnlyTemplate = null;

function createActualProgressCellContent(task, taskMetrics) {
const label = document.createElement('label');
if (!actualProgressLabelTemplate) {
actualProgressLabelTemplate = document.createElement('label');
actualProgressSrOnlyTemplate = document.createElement('span');
actualProgressSrOnlyTemplate.className = 'sr-only';
}
const label = actualProgressLabelTemplate.cloneNode(false);
const fieldId = `actual-progress-${task.id}`;
label.htmlFor = fieldId;
const srOnly = document.createElement('span');
srOnly.className = 'sr-only';

const srOnly = actualProgressSrOnlyTemplate.cloneNode(false);
const rowEntityName = task.task || task.activity || task.phase || '작업';
srOnly.textContent = `실적진척상태 - ${rowEntityName}`;
if (!actualProgressSelectTemplate) {
Expand All @@ -1021,9 +1046,12 @@ function createActualProgressCellContent(task, taskMetrics) {

const warning = taskMetrics.plannedDateWarning || taskMetrics.actualDateWarning;
if (warning) {
const validation = document.createElement('div');
if (!validationMessageTemplate) {
validationMessageTemplate = document.createElement('div');
validationMessageTemplate.className = 'validation-message';
}
const validation = validationMessageTemplate.cloneNode(false);
validation.id = `actual-progress-error-${task.id}`;
validation.className = 'validation-message';
validation.textContent = warning;
label.appendChild(validation);
select.setAttribute('aria-invalid', 'true');
Expand Down
10 changes: 8 additions & 2 deletions cloud-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -740,8 +740,14 @@ function openReportModal() {
// hand-edited files ever matter.
export function parseMsProjectXml(xml) {
const tag = (block, name) => {
const m = block.match(new RegExp(`<${name}>([^<]*)</${name}>`));
return m ? m[1].trim() : '';
// Avoid dynamic RegExp with user-controlled input (ReDoS vulnerability)
const openTag = `<${name}>`;
const closeTag = `</${name}>`;
const start = block.indexOf(openTag);
if (start === -1) return '';
const end = block.indexOf(closeTag, start + openTag.length);
if (end === -1) return '';
return block.substring(start + openTag.length, end).trim();
Comment on lines +743 to +750

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

<Task> 블록 추출의 ReDoS 경로도 제거해야 합니다.

현재 변경은 tag()의 동적 정규식만 제거하지만, parseMsProjectXml의 xml.match(/<Task>[\s\S]*?<\/Task>/g)는 닫는 태그가 없는 입력에 여러 <Task>가 포함될 경우 각 시작점에서 문자열 끝까지 재탐색하여 O(n²)로 실행될 수 있습니다. 사용자 업로드 XML로 메인 스레드가 멈출 수 있으므로, <Task> 추출도 cursor 기반 순회나 XML parser로 선형 처리하세요.

권장 수정
-  const blocks = xml.match(/<Task>[\s\S]*?<\/Task>/g) || [];
+  const blocks = [];
+  let cursor = 0;
+  while (true) {
+    const start = xml.indexOf('<Task>', cursor);
+    if (start === -1) break;
+    const end = xml.indexOf('</Task>', start + 6);
+    if (end === -1) break;
+    blocks.push(xml.slice(start, end + 7));
+    cursor = end + 7;
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cloud-sync.js` around lines 743 - 750, Update parseMsProjectXml’s Task-block
extraction to remove the global lazy-regex path
(`xml.match(/<Task>[\s\S]*?<\/Task>/g)`) and process the XML with cursor-based
scanning or an XML parser in linear time. Preserve extraction of complete
`<Task>...</Task>` blocks while ensuring inputs with multiple unterminated Task
starts do not rescan the remaining string from each start.

};
const unescape = (s) => s
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
Expand Down
2 changes: 2 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
<title>ScopeWeave Planner</title>
<link rel="preload" href="styles.css" as="style" />
<link rel="modulepreload" href="app.js" />
<link rel="modulepreload" href="cloud-sync.js" />
<link rel="modulepreload" href="analytics.js" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
Expand Down
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"fuzz": "node --test tests/fuzz/*.mjs"
},
"dependencies": {
"@hono/node-server": "^1.19.14",
"@hono/node-server": "^2.0.12",
"hono": "^4.12.27"
},
"devDependencies": {
Expand Down
12 changes: 6 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading