app
Adom Gantt
Public Made by Adomby adom
Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, push
← Commit history
0.8.3: exports disagreed with the screen, a dateless row bricked the chart, and the brand fonts had never loaded
The server's milestone ladder was missing the stable-key rung the client's has.
The v3 sidecar carries a key, resolveMs resolves by it first, and the server
started at the name and fell back to the reorder-stale index, so every
mergedData consumer was wrong together: /export/json, /export/csv,
/export/html and the SVG all disagreed with the live UI. Measured against a
sidecar where a milestone had been renamed and moved past another, the old
ladder silently dropped both the rename and the owner. Ported the ladder
verbatim; both copies now say they must stay identical, because they are the
same algorithm written twice, which is how this happened.
A dateless row took the whole chart down. Our own CSV importer accepted a task
with no end or a milestone with no date and returned ok:true; on the next load
parseISO split an undefined and the build died. Measured on 0.8.2 with three bad
rows in a 73-row plan: 0 bars, 0 table rows, one uncaught exception. Fixed at
both ends, since either alone leaves the hole open: all four importers reject
the file with a 400 naming the offending rows, and the viewer drops rows it
cannot date and says which. Rejected rather than defaulted on purpose: an
invented date is indistinguishable from a real one once it is in the file.
Changelog markdown could be line-spoofed. /log is unauthenticated and takes
free-form strings that embedded verbatim in ?format=md, so a newline could
fabricate its own '- [rev 999] admin approved ...' line. One event, one line.
And the brand fonts had never once loaded: the @font-face rules pointed at
adom.inc, which serves the woff2 files with no Access-Control-Allow-Origin
header, and an @font-face fetch is always a CORS request. Familjen Grotesk and
JetBrains Mono now come from Google Fonts; Satoshi is declared against
cdn.fontshare.com, which also sends ACAO. All three verified loading in a real
browser. Fallback stacks unchanged. Adding the header on adom.inc would fix this
for every Adom app at once and is worth raising separately.
7 files changed
+195−29
README.md+1−1@@ -22,7 +22,7 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **Undo.** Ctrl+Z undoes date edits, dependency changes, drag operations, row reorders, and table edits to owner, status and notes (100-level stack). -**Import.** JSON (native format), CSV, MS Project XML, and Jira CSV. Drag-and-drop or file picker.+**Import.** JSON (native format), CSV, MS Project XML, and Jira CSV. Drag-and-drop or file picker. A file with rows that carry no usable date is rejected whole, naming the rows, rather than having a date invented for it. A row that cannot be dated is left out of the chart with a warning instead of blanking the plan. **Export.** JSON, CSV, SVG, and self-contained HTML (no server needed to view the snapshot). CSV carries the phase by name, so an export re-imported keeps its phases, and any cell opening with a formula character is apostrophe-guarded so a spreadsheet cannot execute it. Import strips the guard back off.
SKILL.md+13−2@@ -135,8 +135,8 @@ Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`. |-------|------|----------|-------------| | id | string | yes | Unique identifier (used for deps and state) | | name | string | yes | Task label |-| start | YYYY-MM-DD | yes | Start date |-| end | YYYY-MM-DD | yes | End date |+| start | YYYY-MM-DD | yes | Start date. Required: the chart is a function of dates |+| end | YYYY-MM-DD | yes | End date. Required | | phase | number | yes | Index into phases[] | | owner | string | no | The one person accountable. A single name, never a team. Drives the table's owner filter | | status | string | no | One of: "live", "planned", "not-started" |@@ -294,6 +294,17 @@ curl -s -X POST localhost:8901/log -H 'Content-Type: application/json' \ The log is append-only and never rewritten. `POST /changelog/reset` deletes it outright, so only run it when the user asks for a clean slate. +## Dates are required++A task needs a `start` and an `end`; a milestone needs a `date`, each looking like+`2026-08-27`. All four import endpoints reject the whole file with a 400 naming the+offending rows rather than defaulting a date, because an invented date is+indistinguishable from a real one once it is in the file.++A hand-edited data file never passes through an importer, so the viewer is tolerant+too: a row it cannot date is left out of the chart and named in a warning, rather+than taking the other 72 rows down with it. Fix the data file and reload.+ ## Group labels Do not put a date range in a group label. `"Phase 1 - Design (Jan-Apr 2026)"` renders as
docs/BUILD-NOTES.md+50@@ -3,6 +3,56 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-07 (0.8.3, final adversary round)++**The server's milestone ladder was missing the key rung the client had.** The v3+sidecar carries a stable `key`, `resolveMs` in the client resolves by it first, and+`resolveMilestone` on the server did not look at it at all: it started at the name+and fell back to the reorder-stale index. Every `mergedData()` consumer was wrong+together, so `/export/json`, `/export/csv`, `/export/html` and the SVG all+disagreed with the screen. Measured against a sidecar where a milestone had been+renamed and moved past another: the old ladder silently dropped both the rename and+the owner, leaving the export showing a name nobody had used since. Ported the+ladder verbatim. The two implementations now carry a comment saying they must stay+identical, because they are the same algorithm written twice, which is exactly how+this happened.++**A dateless row bricked the whole chart.** Our own CSV importer accepted a task+with an empty end or a milestone with an empty date and returned `ok: true`; on the+next load `parseISO` split an undefined, the exception escaped the build, and the+page was dead with no way back but hand-editing the file. Measured on the 0.8.2+client with three bad rows in a 73-row plan: **0 bars, 0 table rows, one uncaught+`Cannot read properties of undefined`**. Fixed at both ends, because either alone+leaves the hole open: all four importers now run `assertDates` before they write+anything, and the client drops undateable rows at load and says which ones.++Rejecting at import rather than defaulting, deliberately. A defaulted date is+indistinguishable from a real one the moment it is in the file: it renders, it+sorts, it gets scheduled against, and nothing ever says it was invented. A 400 that+names the offending rows costs a minute and cannot quietly become someone's+deadline. The client still has to be tolerant regardless, because a hand-edited+data file never goes through an importer.++**Changelog markdown could be line-spoofed.** `/log` is unauthenticated and takes+free-form strings, which were embedded verbatim in `?format=md`, so a newline inside+a description could fabricate its own `- [rev 999] ... admin approved ...` line and+put words in another actor's mouth. Newlines are collapsed at render time. One event+is one line, always.++**The brand fonts had never once loaded.** The `@font-face` rules pointed at+`https://adom.inc/fonts/`, which serves the `.woff2` files with **no+Access-Control-Allow-Origin header** (verified: 200, no ACAO). A font fetched by+`@font-face` is always a CORS request, so every one was blocked and the app has+rendered in the fallback system stack from the beginning. Familjen Grotesk and+JetBrains Mono now come from Google Fonts, which sends `ACAO: *`. Satoshi is not on+Google Fonts, and Fontshare's CSS endpoint loads but hands the browser no+`@font-face` rules at all (`link.sheet` is non-null, zero faces registered), so the+two Satoshi weights are declared directly against `cdn.fontshare.com`, which does+send `ACAO: *`. Verified in a real browser: all three families report `loaded`, no+face errors. The fallback stacks are untouched. The other possible fix is to add the+header on adom.inc, which would be better for every Adom app at once and is worth+raising separately.+ ## Build notes, 2026-08-07 (0.8.2, behavioural verification pass) A verifier drove the app as a user and found two bugs that only show up in use.
gantt.html+66−8@@ -5,13 +5,29 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>adom-gantt</title> <link rel="icon" href="favicon.svg">+<!-- Brand faces: Familjen Grotesk for headings, Satoshi for body, JetBrains Mono+ for data. These were @font-face rules pointing at https://adom.inc/fonts/,+ which serves the .woff2 files with no Access-Control-Allow-Origin header.+ A font fetched by @font-face is always a CORS request, so every one of them+ was blocked and the app has been rendering in the fallback system stack the+ whole time. Both hosts below send ACAO on the font files themselves. The+ fallback stacks in the rules further down are unchanged and still carry the+ page if these hosts are unreachable. -->+<link rel="preconnect" href="https://fonts.googleapis.com">+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>+<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin>+<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400&display=swap"> <style>-@font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2'); font-weight: 700; font-display: swap; }-@font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2'); font-weight: 600; font-display: swap; }-@font-face { font-family: 'Familjen Grotesk'; src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }-@font-face { font-family: 'Satoshi'; src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }-@font-face { font-family: 'Satoshi'; src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }-@font-face { font-family: 'JetBrains Mono'; src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }+/* Satoshi is not on Google Fonts. Fontshare's CSS endpoint at api.fontshare.com+ loads but hands the browser no @font-face rules, so it is skipped and the two+ faces we use are declared here against cdn.fontshare.com, which does send+ Access-Control-Allow-Origin: *. The URLs are content-addressed and immutable;+ if the CDN ever goes away the fallback stack below carries the page, which is+ the same thing that happens today. */+@font-face { font-family: 'Satoshi'; font-weight: 400; font-style: normal; font-display: swap;+ src: url('https://cdn.fontshare.com/wf/TTX2Z3BF3P6Y5BQT3IV2VNOK6FL22KUT/7QYRJOI3JIMYHGY6CH7SOIFRQLZOLNJ6/KFIAZD4RUMEZIYV6FQ3T3GP5PDBDB6JY.woff2') format('woff2'); }+@font-face { font-family: 'Satoshi'; font-weight: 500; font-style: normal; font-display: swap;+ src: url('https://cdn.fontshare.com/wf/P2LQKHE6KA6ZP4AAGN72KDWMHH6ZH3TA/ZC32TK2P7FPS5GFTL46EU6KQJA24ZYDB/7AHDUZ4A7LFLVFUIFSARGIWCRQJHISQP.woff2') format('woff2'); } /* Adom is a dark system: dark is the base, light is the opt-in override. Tokens and contrast ratios come from the adom-ui-design skill.@@ -248,6 +264,15 @@ body.col-resizing { cursor: col-resize; user-select: none; } .undo-toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--elevated); border: 1px solid var(--border); border-radius: 8px; padding: 8px 16px; font-size: 12px; color: var(--text2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 99998; opacity: 0; transition: opacity 0.15s; pointer-events: none; } .undo-toast.show { opacity: 1; } .undo-toast kbd { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); }+/* A plan that could not be fully rendered says so, and keeps saying so: a silently+ shorter plan is worse than an ugly bar. */+.data-warn { display: none; position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); max-width: 640px; background: var(--elevated); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 8px; padding: 10px 14px; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text-body); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 99999; }+.data-warn.show { display: block; }+.data-warn .dw-title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; color: var(--text); margin-bottom: 4px; }+.data-warn .dw-rows { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); margin-top: 6px; word-break: break-word; }+.data-warn .dw-close { position: absolute; top: 6px; right: 10px; background: none; border: none; color: var(--text2); font-size: 14px; cursor: pointer; line-height: 1; }+.data-warn .dw-close:hover { color: var(--text); }+@media print { .data-warn { display: none !important; } } .save-indicator { position: fixed; bottom: 16px; right: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); z-index: 99998; opacity: 0; transition: opacity 0.3s; } .save-indicator.show { opacity: 1; } .rev-indicator { position: fixed; bottom: 16px; left: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); z-index: 99998; cursor: default; }@@ -368,6 +393,7 @@ input[type="file"] { display: none; } <div class="dropdown" id="exportMenu"></div> <div class="dropdown" id="importMenu"></div> <div class="undo-toast" id="undoToast">Undid change · <kbd>Ctrl+Z</kbd></div>+<div class="data-warn" id="dataWarn"></div> <div class="save-indicator" id="saveInd">saved</div> <div class="rev-indicator" id="revInd">rev 0</div> <div class="datepop" id="datePop"></div>@@ -406,7 +432,25 @@ const { project, phases, items } = projData; // permanently. An index cannot go stale when the text changes. items.forEach((r,i)=>{ if(!r.id) r._key='#row'+i; }); function rowOrderKey(r){ return r.id || r._key; }-function msKey(r){ return r._key || r.name; }+// Milestones use the same stable key as every other row. The server recomputes it+// as `r.id || '#row' + dataFileIndex`, so the two sides must agree exactly or an+// export resolves a sidecar entry onto the wrong milestone.+function msKey(r){ return rowOrderKey(r); }++// A row with no usable date used to take the whole chart down with it: parseISO+// split an undefined during the build, the exception escaped, and one bad row+// blanked all 72 good ones with no way back except hand-editing the data file.+// Drop the row, keep the plan, and say so out loud.+function isDateStr(s){ return typeof s==='string' && /^\d{4}-\d{1,2}(-\d{1,2})?$/.test(s); }+function rowDatesOk(r){+ if(!r||r.type==='group')return true;+ if(r.type==='milestone')return isDateStr(r.date);+ return isDateStr(r.start)&&isDateStr(r.end);+}+// Keys are assigned above, over the FULL data-file array, before anything is+// dropped, so the surviving rows keep the keys the server computes for them.+const UNDATED=items.filter(r=>!rowDatesOk(r));+if(UNDATED.length){ for(let i=items.length-1;i>=0;i--){ if(!rowDatesOk(items[i]))items.splice(i,1); } } document.title = `${project.title} · adom-gantt`; // ── View preferences (theme, milestone style, start-date override) ──@@ -453,7 +497,9 @@ applyView(); // Revert goes back to. function isoDay(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } function isoMonth(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; }-function parseISO(ds){ const[y,m,d]=ds.split('-').map(Number); return new Date(y,(m||1)-1,d||1); }+// Undateable rows are dropped at load, so this should never see junk. It is+// defensive anyway: a throw in here used to blank the entire chart.+function parseISO(ds){ const[y,m,d]=String(ds==null?'':ds).split('-').map(Number); return new Date(y||1970,(m||1)-1,d||1); } function scanRange(){ let minD='9999-12-31', maxD='0000-01-01';@@ -2070,6 +2116,18 @@ drawDeps(); // "What's mine" is a standing question, so the owner filter survives a reload // (syncOwnerControls picks prefs.tableOwner up on this first render). renderTable();+// Say it after the first paint, so the message sits over a chart that clearly+// still works rather than over a blank page.+if(UNDATED.length){+ const dw=$('dataWarn');+ const names=UNDATED.map(r=>r.id||r.name||r.label||'(unnamed)');+ dw.innerHTML=`<button class="dw-close" title="Dismiss">×</button>+ <div class="dw-title">${UNDATED.length} row${UNDATED.length>1?'s':''} left out of this chart</div>+ <div>A task needs a start and an end; a milestone needs a date. The rest of the plan is drawn and editable, and these rows are untouched in the data file.</div>+ <div class="dw-rows">${esc(names.slice(0,12).join(', '))}${names.length>12?', and '+(names.length-12)+' more':''}</div>`;+ dw.classList.add('show');+ dw.querySelector('.dw-close').addEventListener('click',()=>dw.classList.remove('show'));+} })(); </script>
package.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.8.2",+ "version": "0.8.3", "description": "Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": {
page.json+1−1@@ -2,7 +2,7 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.8.2",+ "version": "0.8.3", "description": "Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": {
server.js+63−16@@ -111,6 +111,35 @@ function appendLog(events, actor) { return { rev, added: rows.length, rows }; } +// Every importer runs this before it writes anything. A task with no dates or a+// milestone with no date is not a slightly-wrong row: the chart is a function of+// dates, so it is a row that cannot be drawn, and it used to be accepted with+// ok:true and then take the whole chart down on the next load.+//+// Rejected, not defaulted, on purpose. A defaulted date is indistinguishable from+// a real one once it is in the file: it renders, it sorts, it gets scheduled+// against, and nothing ever says it was invented. A 400 that names the offending+// rows is fixable in a minute and cannot quietly become someone's deadline.+const DATE_RE = /^\d{4}-\d{1,2}(-\d{1,2})?$/;+function assertDates(items) {+ const bad = [];+ (items || []).forEach((r, i) => {+ if (!r || r.type === 'group') return;+ const who = r.id || r.name || r.label || `row ${i + 1}`;+ if (r.type === 'milestone') {+ if (!DATE_RE.test(String(r.date || ''))) bad.push(`${who} (milestone needs a date)`);+ return;+ }+ if (!DATE_RE.test(String(r.start || '')) || !DATE_RE.test(String(r.end || ''))) {+ bad.push(`${who} (task needs a start and an end)`);+ }+ });+ if (bad.length) {+ const shown = bad.slice(0, 10).join('; ');+ throw new Error(`${bad.length} row(s) have no usable date, so nothing was imported. Dates must look like 2026-08-27. Offending rows: ${shown}${bad.length > 10 ? `; and ${bad.length - 10} more` : ''}`);+ }+}+ // An import replaces the data file wholesale and clears the sidecar, which is the // single most destructive thing the app can do, and it used to leave no trace at // all: an agent reading the changelog could not tell that every event above it@@ -188,8 +217,14 @@ function describeEvent(e) { return `${who} ${action} ${name}${via}`; } +// Every string in an event is attacker-controlled: /log is unauthenticated and+// takes free-form fields. Rendered into markdown verbatim, an embedded newline+// let a description fabricate its own "- [rev 999] ..." line and put words in+// another actor's mouth. One log event is one line, always.+function oneLine(s) { return String(s == null ? '' : s).replace(/[\r\n\u2028\u2029]+/g, ' ').trim(); }+ function changelogMarkdown(log, since, rev) {- const title = (() => { try { return loadData().project.title; } catch { return 'project'; } })();+ const title = (() => { try { return oneLine(loadData().project.title); } catch { return 'project'; } })(); const lines = []; lines.push(`# adom-gantt changelog: ${title}`); lines.push('');@@ -199,7 +234,7 @@ function changelogMarkdown(log, since, rev) { lines.push('No changes recorded' + (since ? ` since rev ${since}.` : ' yet.')); return lines.join('\n') + '\n'; }- const touched = [...new Set(log.filter(e => e.id).map(e => e.id))];+ const touched = [...new Set(log.filter(e => e.id).map(e => oneLine(e.id)))]; if (touched.length) { lines.push(`Items touched: ${touched.join(', ')}`); lines.push('');@@ -209,7 +244,7 @@ function changelogMarkdown(log, since, rev) { log.forEach(e => { const ts = typeof (e && e.ts) === 'string' ? e.ts.slice(0, 19).replace('T', ' ') : ''; const rev = (e && e.rev !== undefined) ? e.rev : '?';- lines.push(`- [rev ${rev}]${ts ? ' ' + ts : ''} ${describeEvent(e)}`);+ lines.push(`- [rev ${rev}]${ts ? ' ' + ts : ''} ${oneLine(describeEvent(e))}`); }); return lines.join('\n') + '\n'; }@@ -222,19 +257,26 @@ function setOpt(r, k, v) { else r[k] = v; } -// Sidecar milestone entries carry both an index and a name, and either can go-// stale: a reorder moves the index, a rename moves the name. Resolve with a-// preference ladder and claim each milestone at most once, so two entries can-// never collapse onto one row and a rename is not mistaken for a new milestone.+// Sidecar milestone entries carry a stable key, an index and a name, and all but+// the key can go stale: a reorder moves the index, a rename moves the name.+// Resolve down a ladder and claim each milestone at most once. //-// 1. exactly one unclaimed milestone with this name (survives a reorder)-// 2. the milestone at this index, if unclaimed (survives a rename)-// 3. the first unclaimed milestone with this name (duplicate names)+// 1. the stable row key (survives both)+// 2. exactly one unclaimed milestone with this name (survives a reorder)+// 3. the milestone at this index, if unclaimed (survives a rename)+// 4. the first unclaimed milestone with this name (duplicate names) //-// The old code was `find(name) || ms[i]`, where find always won and always-// returned the FIRST match, so an edit to the second of two same-named-// milestones landed on the first and was lost.-function resolveMilestone(ms, sv, claimed) {+// This MUST stay identical to resolveMs in gantt.html. It briefly did not: the+// server ladder had no key rung, so a milestone that was renamed and then moved+// resolved by its stale index and the export handed one milestone's new name and+// date to a different one, losing that one's date, while the live UI was correct.+// `msKeys[j]` is the stable key of `ms[j]`, computed the same way the client does:+// the row's id, or its index in the data file.+function resolveMilestone(ms, msKeys, sv, claimed) {+ if (sv.key) {+ const k = msKeys.indexOf(sv.key);+ if (k >= 0 && !claimed.has(k)) return k;+ } const byName = []; ms.forEach((m, i) => { if (!claimed.has(i) && m.name === sv.name) byName.push(i); }); if (byName.length === 1) return byName[0];@@ -278,11 +320,12 @@ function mergedData() { setOpt(r, 'description', sv.desc); }); if (Array.isArray(state)) return data;- const ms = data.items.filter(r => r.type === 'milestone');+ const ms = [], msKeys = [];+ data.items.forEach((r, i) => { if (r.type === 'milestone') { ms.push(r); msKeys.push(r.id || ('#row' + i)); } }); const claimed = new Set(); (state.milestones || []).forEach(sv => { if (!sv) return;- const i = resolveMilestone(ms, sv, claimed);+ const i = resolveMilestone(ms, msKeys, sv, claimed); if (i < 0) return; claimed.add(i); const r = ms[i];@@ -407,6 +450,7 @@ function importJSON(body) { if (!Array.isArray(data.phases) || !Array.isArray(data.items)) { throw new Error('phases and items must be arrays'); }+ assertDates(data.items); fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8'); // Clear state file since data structure changed if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);@@ -503,6 +547,7 @@ function importCSV(body, queryString) { items }; + assertDates(data.items); fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8'); if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE); return { ok: true, items: items.length, phases: phases.length };@@ -589,6 +634,7 @@ function importMSProjectXML(body) { items: tasks }; + assertDates(data.items); fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8'); if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE); return { ok: true, items: tasks.length };@@ -684,6 +730,7 @@ function importJiraCSV(body) { items: validItems }; + assertDates(data.items); fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8'); if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE); return { ok: true, items: validItems.length };