← Commit history

0.8.4: /export/svg 500'd on a hand-corrupted data file while every other path coped

Drew Owens ·40015e5e88 · ·parent f937ea5
generateSVG builds its own date range and its own toX, so it went through neither the import guard nor the viewer's drop-and-warn: a file hand-edited to carry a dateless row returned 500 'Cannot read properties of undefined (reading split)' while the viewer, /export/json, /export/csv and /export/html all handled the same file. It now filters with the same rowDatesOk the importers use, and names what it left out in an XML comment rather than shipping a quietly shorter chart. Two more in the same function and the same defect class: a phase with no usable start or end threw from the phase loop for the same reason, and a file where nothing carries a date produced a negative width. Both guarded; the second draws a single empty month. Output is byte-identical to 0.8.3 on a clean file.
4 files changed +55−13
docs/BUILD-NOTES.md+18
@@ -3,6 +3,24 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-07 (0.8.4)++One residual from the 0.8.3 dateless-row work. The viewer, `/export/json`,+`/export/csv` and `/export/html` all coped with a hand-corrupted data file, but+`/export/svg` still returned a 500 (`Cannot read properties of undefined (reading+'split')`), because `generateSVG` builds its own date range and its own `toX` and+so never went through either of the guards. It now filters with the same+`rowDatesOk` the importers use, and names what it left out in an XML comment at the+top of the file rather than shipping a quietly shorter chart.++Two things fixed alongside it in the same function, same defect class: a phase with+no usable start or end threw from the phase loop for the same reason, and a file+where nothing at all carries a date produced a negative width. Both are guarded;+the second falls back to drawing a single empty month.++Verified byte-identical output to 0.8.3 on a clean file, so nothing about the+normal path moved.+ ## 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
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.8.3",+  "version": "0.8.4",   "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.3",+  "version": "0.8.4",   "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+35−11
@@ -121,18 +121,19 @@ function appendLog(events, actor) { // 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})?$/;+// The same rule the viewer applies in gantt.html: a task needs a start and an end,+// a milestone needs a date, a group needs neither.+function rowDatesOk(r) {+  if (!r || r.type === 'group') return true;+  if (r.type === 'milestone') return DATE_RE.test(String(r.date || ''));+  return DATE_RE.test(String(r.start || '')) && DATE_RE.test(String(r.end || ''));+} 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 (rowDatesOk(r)) return;+    const who = (r && (r.id || r.name || r.label)) || `row ${i + 1}`;+    bad.push(`${who} (${r && r.type === 'milestone' ? 'milestone needs a date' : 'task needs a start and an end'})`);   });   if (bad.length) {     const shown = bad.slice(0, 10).join('; ');@@ -740,7 +741,16 @@ function importJiraCSV(body) {  function generateSVG() {   const data = mergedData();-  const { project, phases, items } = data;+  const { project, phases } = data;++  // Imports reject undateable rows, but a hand-edited data file never goes through+  // an importer, and this path used to 500 on one while the viewer and the other+  // three exports all coped. Drop what cannot be drawn, same rule as the viewer,+  // and say so in a comment rather than silently shipping a shorter chart.+  const allItems = data.items || [];+  const items = allItems.filter(rowDatesOk);+  const dropped = allItems.filter(r => !rowDatesOk(r))+    .map((r, i) => (r && (r.id || r.name || r.label)) || `row ${i + 1}`);    const COL = 66;   const ROW_H = 34;@@ -758,12 +768,21 @@ function generateSVG() {     if (r.date && r.date > maxDate) maxDate = r.date;   });   phases.forEach(p => {+    // A phase with no usable start/end is the same class of hand-edit as a+    // dateless row, and split() on it threw from the same function.+    if (!p || !DATE_RE.test(String(p.start || '')) || !DATE_RE.test(String(p.end || ''))) return;     const ps = p.start + '-01';     const [ey, em] = p.end.split('-').map(Number);     const pe = `${ey}-${String(em).padStart(2, '0')}-28`;     if (ps < minDate) minDate = ps;     if (pe > maxDate) maxDate = pe;   });+  // Nothing in the file carried a date. Draw an empty month rather than a chart+  // with a negative width.+  if (minDate > maxDate) {+    const now = new Date();+    minDate = maxDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;+  }    const [minY, minM] = minDate.split('-').map(Number);   const [maxY, maxM] = maxDate.split('-').map(Number);@@ -785,7 +804,12 @@ function generateSVG() {    const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); -  let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="${totalH}" viewBox="0 0 ${totalW} ${totalH}">+  // "--" cannot appear inside an XML comment, and these names come from the file.+  const dropNote = dropped.length+    ? `\n<!-- ${dropped.length} row(s) left out: no usable date. ${esc(dropped.slice(0, 12).join(', ')).replace(/-{2,}/g, '-')}${dropped.length > 12 ? `, and ${dropped.length - 12} more` : ''} -->`+    : '';++  let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="${totalH}" viewBox="0 0 ${totalW} ${totalH}">${dropNote} <defs>   <style>     .bg { fill: #0d1117; }