123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380
#!/usr/bin/env node
const http = require('http');
const fs = require('fs');
const path = require('path');
const url_ = require('url');

const args = process.argv.slice(2);
const dataIdx = args.indexOf('--data');
const portIdx = args.indexOf('--port');
const DATA_FILE = dataIdx >= 0 ? path.resolve(args[dataIdx + 1]) : null;
const PORT = portIdx >= 0 ? parseInt(args[portIdx + 1], 10) : 8901;

if (!DATA_FILE) {
  console.error('Usage: node server.js --data <project.json> [--port 8901]');
  process.exit(1);
}
if (!fs.existsSync(DATA_FILE)) {
  console.error(`Data file not found: ${DATA_FILE}`);
  process.exit(1);
}

const STATE_FILE = DATA_FILE.replace(/\.json$/, '-state.json');
const LOG_FILE = DATA_FILE.replace(/\.json$/, '-changelog.jsonl');
const APP_DIR = __dirname;
// Read from our own manifest per request, not once at boot. gantt.html is already
// read per request, so a deploy that only changes the HTML needs no restart; if
// the version were cached at startup that deploy would leave the header showing
// the previous release, which is exactly the drift this was built to prevent.
// Cheap: a small file, and only on the page request.
function appVersion() {
  try { return JSON.parse(fs.readFileSync(path.join(APP_DIR, 'package.json'), 'utf8')).version || ''; }
  catch { return ''; }
}
function serveApp() {
  return fs.readFileSync(path.join(APP_DIR, 'gantt.html'), 'utf8').split('__APP_VERSION__').join(appVersion());
}
const DATA_DIR = path.dirname(DATA_FILE);

const MIME = {
  '.html': 'text/html', '.js': 'application/javascript',
  '.css': 'text/css', '.json': 'application/json',
  '.svg': 'image/svg+xml', '.png': 'image/png',
};

// ── Helpers ──

// 8 MB is far above any real plan and far below anything that hurts. Without a
// cap an unauthenticated POST can grow the process until it dies.
const MAX_BODY = 8 * 1024 * 1024;

function readBody(req) {
  return new Promise((resolve, reject) => {
    let body = '', size = 0, done = false;
    req.on('data', c => {
      if (done) return;
      size += c.length;
      if (size > MAX_BODY) {
        done = true;
        req.destroy();
        reject(new Error(`request body over ${MAX_BODY} bytes`));
        return;
      }
      body += c;
    });
    req.on('end', () => { if (!done) { done = true; resolve(body); } });
    req.on('error', e => { if (!done) { done = true; reject(e); } });
  });
}

function jsonResp(res, code, obj) {
  res.writeHead(code, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(obj));
}

function loadData() {
  return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
}

function loadState() {
  if (fs.existsSync(STATE_FILE)) {
    return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
  }
  return null;
}

// ── Changelog ──
//
// An append-only JSONL record of every edit, so an AI picking this project back
// up can read what a human changed since it last looked and build on top of it
// instead of overwriting it. One JSON object per line, never rewritten.
//
//   { rev, ts, actor, action, id, name, from, to, shift, via }
//
// `rev` is a monotonic counter. An agent notes the rev it has seen, then asks
// for /changelog?since=<rev> next time to get only what changed in between.

function readLog() {
  if (!fs.existsSync(LOG_FILE)) return [];
  return fs.readFileSync(LOG_FILE, 'utf8')
    .split('\n').filter(Boolean)
    .map(l => { try { return JSON.parse(l); } catch { return null; } })
    .filter(Boolean);
}

function currentRev() {
  const log = readLog();
  return log.length ? log[log.length - 1].rev : 0;
}

function appendLog(events, actor) {
  let rev = currentRev();
  const ts = new Date().toISOString();
  // The server's own fields go on LAST. Spreading the caller's event last let a
  // posted `rev` overwrite the counter, which breaks ?since= monotonicity for
  // every reader after it.
  const rows = events.map(e => ({
    ...e,
    rev: ++rev,
    ts: e.ts || ts,
    actor: actor || e.actor || 'user',
  }));
  fs.appendFileSync(LOG_FILE, rows.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf8');
  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})?$/;
// 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 (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('; ');
    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` : ''}`);
  }
}

// Bake the merged plan into the data file and empty the sidecar.
//
// The sidecar is a delta over the data file, keyed by each row's ORIGINAL index
// (#row<n>). That makes editing the data file underneath a live sidecar unsafe:
// removing a row renumbers every key below it and silently re-points every
// override. Compacting resolves that once. After it, the file IS the plan, the
// sidecar has nothing left to say, and a structural edit is just an edit.
//
// The changelog is append-only history and is NOT touched: earlier events refer
// to the plan as it was, which is exactly what history means.
function logCompact(info) {
  try {
    appendLog([{ action: 'compact', items: info.items, adds: info.adds, deletes: info.deletes, via: 'compact' }], 'user');
  } catch (e) {
    console.error('adom-gantt: could not record the compact in the changelog:', e.message);
  }
}

// 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
// referred to a plan that no longer exists. Never let a failed log kill the import.
function logImport(format, result) {
  try {
    appendLog([{ action: 'import', format, items: result.items, phases: result.phases, via: 'import' }], 'user');
  } catch (e) {
    console.error('adom-gantt: could not record the import in the changelog:', e.message);
  }
}

// Total by construction. The log is append-only and anyone can POST to /log, so a
// single event missing a field it "should" have used to throw here, and the throw
// surfaced as a dead server (see the /changelog handler). Every field access goes
// through fld(); an event that says nothing useful degrades to a bare sentence.
function describeEvent(e) {
  if (!e || typeof e !== 'object') return 'unreadable event';
  const fld = (side, k, dflt) => {
    const o = e[side];
    if (!o || typeof o !== 'object' || o[k] === undefined || o[k] === null) return dflt;
    return o[k];
  };
  const num = (side, k) => { const v = fld(side, k, 0); return typeof v === 'number' ? v : 0; };
  const sgn = n => (n > 0 ? '+' : '') + n;
  const who = e.actor || 'user';
  const via = e.via ? ` (${e.via})` : '';
  const name = e.name ? `"${e.name}"` : (e.id || 'an item');
  const action = typeof e.action === 'string' ? e.action : 'changed';
  if (action === 'dates') {
    const ds = num('shift', 'start'), de = num('shift', 'end');
    const move = (ds === de && ds !== 0) ? `moved ${sgn(ds)}d` : `start ${sgn(ds)}d, end ${sgn(de)}d`;
    return `${who} ${move} on ${name}: ${fmtDate(fld('from', 'start', ''))}..${fmtDate(fld('from', 'end', ''))} -> ${fmtDate(fld('to', 'start', ''))}..${fmtDate(fld('to', 'end', ''))}${via}`;
  }
  if (action === 'plan.shift') {
    return `${who} moved the whole plan ${sgn(num('shift', 'start'))}d (${e.count || 0} items): starts ${fmtDate(fld('from', 'start', ''))} -> ${fmtDate(fld('to', 'start', ''))}${via}`;
  }
  if (action === 'timeline.start') {
    const f = fld('from', 'start', ''), t = fld('to', 'start', '');
    return `${who} set the timeline to start at ${fmtDate(t)}${f ? ` (was ${fmtDate(f)})` : ''}${via}`;
  }
  if (action === 'milestone') {
    return `${who} moved milestone ${name} ${sgn(num('shift', 'days'))}d: ${fmtDate(fld('from', 'date', ''))} -> ${fmtDate(fld('to', 'date', ''))}${via}`;
  }
  if (action === 'milestone.rename') {
    return `${who} renamed milestone "${fld('from', 'name', '?')}" to "${fld('to', 'name', '?')}"${via}`;
  }
  if (action === 'note') {
    const f = fld('from', 'note', ''), t = fld('to', 'note', '');
    if (!f) return `${who} labelled ${name} "${t}"${via}`;
    if (!t) return `${who} cleared the label on ${name} (was "${f}")${via}`;
    return `${who} relabelled ${name} "${f}" -> "${t}"${via}`;
  }
  if (action === 'owner') {
    const f = fld('from', 'owner', ''), t = fld('to', 'owner', '');
    if (!f) return `${who} assigned ${name} to ${t || 'nobody'}${via}`;
    if (!t) return `${who} left ${name} unassigned (was ${f})${via}`;
    return `${who} reassigned ${name} from ${f} to ${t}${via}`;
  }
  if (action === 'status') {
    return `${who} set ${name} status ${statusText(fld('from', 'status', '')) || 'none'} -> ${statusText(fld('to', 'status', '')) || 'none'}${via}`;
  }
  if (action === 'desc') {
    const f = fld('from', 'desc', ''), t = fld('to', 'desc', '');
    if (!f) return `${who} added notes to ${name}: "${t}"${via}`;
    if (!t) return `${who} cleared the notes on ${name}${via}`;
    return `${who} rewrote the notes on ${name}: "${t}"${via}`;
  }
  if (action === 'rename') {
    return `${who} renamed "${fld('from', 'name', '?')}" to "${fld('to', 'name', '?')}"${via}`;
  }
  if (action === 'phase') {
    return `${who} moved ${name} from the ${fld('from', 'phase', '?')} lane to ${fld('to', 'phase', '?')}${via}`;
  }
  if (action === 'add') {
    return `${who} added a new ${e.kind || 'item'} to the plan: ${name}${via}`;
  }
  if (action === 'compact') {
    return `${who} FLATTENED live edits into the data file: ${e.items || 0} item(s) are now the base and the sidecar is empty. Events above this line describe the plan before the flatten${via}`;
  }
  if (action === 'lane.remove') {
    const kept = Array.isArray(e.rehomed) && e.rehomed.length ? ` Kept and re-homed to ${e.into || 'another lane'}: ${e.rehomed.join('; ')}.` : '';
    const gone = Array.isArray(e.removed) && e.removed.length ? ` Removed: ${e.removed.join('; ')}.` : ' Nothing else was in it.';
    return `${who} removed the "${e.lane}" lane.${gone}${kept}${via}`;
  }
  if (action === 'restore') {
    return `${who} put ${name} back into the plan${via}`;
  }
  if (action === 'delete') {
    return `${who} DELETED ${name} from the plan${e.deps ? ` (and ${e.deps} dependency link(s) on it)` : ''}${via}`;
  }
  if (action === 'provenance') {
    const f = fld('from', 'label', ''), t = fld('to', 'label', '');
    if (!f) return `${who} recorded where ${name} came from: "${t}"${via}`;
    if (!t) return `${who} cleared the source on ${name} (was "${f}")${via}`;
    return `${who} changed the source on ${name}: "${f}" -> "${t}"${via}`;
  }
  if (action === 'dep.add') return `${who} made ${name} depend on "${e.depName || e.dep || '?'}"${via}`;
  if (action === 'dep.remove') return `${who} removed ${name}'s dependency on "${e.depName || e.dep || '?'}"${via}`;
  if (action === 'reorder') {
    if (e.hint) return `${who} moved ${e.hint} to a different position in the plan${via}`;
    return `${who} reordered rows: ${(Array.isArray(e.moved) ? e.moved : []).join(', ') || 'row order changed'}${via}`;
  }
  if (action === 'import') {
    return `${who} REPLACED the whole plan by importing ${e.items || 0} item(s) in ${e.phases || 0} phase(s) from ${e.format || 'a file'}. Everything before this point describes a different plan${via}`;
  }
  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 oneLine(loadData().project.title); } catch { return 'project'; } })();
  const lines = [];
  lines.push(`# adom-gantt changelog: ${title}`);
  lines.push('');
  lines.push(`rev ${rev} | ${log.length} event(s)${since ? ` since rev ${since}` : ''} | ${path.basename(DATA_FILE)}`);
  lines.push('');
  if (!log.length) {
    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 => oneLine(e.id)))];
  if (touched.length) {
    lines.push(`Items touched: ${touched.join(', ')}`);
    lines.push('');
    lines.push('Treat these as deliberate human edits. Build on them; do not reset them.');
    lines.push('');
  }
  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 : ''} ${oneLine(describeEvent(e))}`);
  });
  return lines.join('\n') + '\n';
}

// The status vocabulary is four states. Old values are aliased on READ so stored
// data keeps working untouched; only a fresh write uses the new words. Kept in
// step with statusKey() in gantt.html.
const STATUS_ORDER = ['not-started', 'in-progress', 'blocked', 'complete'];
const STATUS_ALIAS = {
  'live': 'in-progress', 'in progress': 'in-progress', 'in_progress': 'in-progress',
  'planned': 'not-started', 'not started': 'not-started', 'notstarted': 'not-started',
  'done': 'complete', 'completed': 'complete',
};
function statusKey(v) {
  const t = String(v == null ? '' : v).trim().toLowerCase();
  if (!t) return '';
  if (STATUS_ORDER.includes(t)) return t;
  return STATUS_ALIAS[t] || t;   // an unknown status survives as itself
}
function statusText(v) { const k = statusKey(v); return k ? k.replace(/-/g, ' ') : ''; }

// Dates a person reads render 17-Aug-2026. Dates a machine reads (event
// payloads, CSV, the data file, the sidecar) stay ISO: this only touches the
// rendering, never the stored value.
const MN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function fmtDate(ds) {
  if (!ds) return '?';
  const [y, mo, d] = String(ds).split('-').map(Number);
  if (!y || !mo) return String(ds);
  return d ? `${String(d).padStart(2, '0')}-${MN[mo - 1]}-${y}` : `${MN[mo - 1]} ${y}`;
}

// An absent field in the sidecar means "the sidecar has nothing to say", so the
// data file's own value stands. An empty one means the user cleared it.
function setOpt(r, k, v) {
  if (v === undefined) return;
  if (v === null || v === '') delete r[k];
  else r[k] = v;
}

// provenance is {label, url} or a bare string, so setOpt's rules do not fit.
function setProv(r, v) {
  if (v === undefined) return;
  if (v === null || v === '' || (typeof v === 'object' && !v.label && !v.url)) delete r.provenance;
  else r.provenance = v;
}

// 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. 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)
//
// 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];
  const i = sv.i;
  if (typeof i === 'number' && ms[i] && !claimed.has(i)) return i;
  if (byName.length) return byName[0];
  return -1;
}

// Row-order keys. An id-less row (a group, a milestone) used to be keyed by its
// own label or name, so renaming one changed its key, the key stopped resolving,
// and the row silently vanished from the plan. Index into the data file instead:
// that never changes when the text does. Legacy label and name keys are still
// accepted so a sidecar written before this change still restores its order.
// Keys are the row's ORIGINAL index in the data file, so they must be computed
// before anything is removed. Filtering first and then numbering would renumber
// every surviving row and break every saved key.
function rowKeysOf(items) { return items.map((r, i) => r.id || r._key || ('#row' + i)); }
function orderKeyMap(items, keys) {
  const m = new Map();
  items.forEach((r, i) => { m.set(keys[i], r); });
  items.forEach(r => { const k = r.label || r.name; if (k && !m.has(k)) m.set(k, r); });
  return m;
}

function mergedData() {
  const data = loadData();
  const state = loadState();
  if (!state) return data;
  // v1 was a bare array of tasks; v2 also carries milestones, phases and row order.
  // Keys first, deletions second, everything else after: the saved order was
  // written against the post-deletion plan, so the order guard has to compare
  // against that. A key that no longer resolves still trips the guard; a key
  // listed in `deleted` is a deliberate removal and does not.
  let keys = rowKeysOf(data.items);
  // Rows created in the app live only in the sidecar, so every reader of the
  // merged plan (json, csv, svg, html) has to fold them back in. Appended with
  // their own keys; the saved order puts them where they belong.
  if (!Array.isArray(state) && Array.isArray(state.added)) {
    state.added.forEach(a => {
      if (!a || typeof a !== 'object') return;
      const k = a._key || a.id;
      if (!k || keys.includes(k)) return;
      const row = Object.assign({}, a);
      delete row._key;
      data.items.push(row);
      keys.push(k);
    });
  }
  if (!Array.isArray(state) && Array.isArray(state.deleted) && state.deleted.length) {
    const gone = new Set(state.deleted);
    const keptItems = [], keptKeys = [];
    data.items.forEach((r, i) => { if (!gone.has(keys[i])) { keptItems.push(r); keptKeys.push(keys[i]); } });
    const removedIds = new Set(data.items.filter((r, i) => gone.has(keys[i])).map(r => r.id).filter(Boolean));
    data.items = keptItems; keys = keptKeys;
    // dependencies on a deleted row go with it, everywhere the data is read
    if (removedIds.size) data.items.forEach(r => {
      if (Array.isArray(r.deps)) r.deps = r.deps.filter(d => !removedIds.has(d));
    });
  }
  const tasks = Array.isArray(state) ? state : state.tasks;
  (tasks || []).forEach(sv => {
    // An entry with no id would match the first id-less row in the file, which is
    // a group header, and write task dates and an owner onto it. Skip it.
    if (!sv || !sv.id) return;
    const r = data.items.find(d => d.id === sv.id);
    if (!r) return;
    if (sv.s) r.start = sv.s;
    if (sv.e) r.end = sv.e;
    if (sv.deps) r.deps = sv.deps;
    if (sv.note !== undefined) r.note = sv.note;
    // v3 fields. Absent in a v2 sidecar, in which case the data file wins.
    setOpt(r, 'owner', sv.owner);
    setOpt(r, 'status', sv.status);
    setOpt(r, 'description', sv.desc);
    setProv(r, sv.prov);
    if (sv.name) r.name = sv.name;
    if (typeof sv.phase === 'number' && data.phases[sv.phase]) r.phase = sv.phase;
  });
  if (Array.isArray(state)) return data;
  const ms = [], msKeys = [];
  data.items.forEach((r, i) => { if (r.type === 'milestone') { ms.push(r); msKeys.push(keys[i]); } });
  const claimed = new Set();
  (state.milestones || []).forEach(sv => {
    if (!sv) return;
    const i = resolveMilestone(ms, msKeys, sv, claimed);
    if (i < 0) return;
    claimed.add(i);
    const r = ms[i];
    if (sv.date) r.date = sv.date;
    // A rename made in the milestone editor is a real edit and has to persist.
    if (sv.name) r.name = sv.name;
    setOpt(r, 'owner', sv.owner);
    setOpt(r, 'status', sv.status);
    setOpt(r, 'description', sv.desc);
    setProv(r, sv.prov);
    if (typeof sv.phase === 'number' && data.phases[sv.phase]) r.phase = sv.phase;
  });
  (state.phases || []).forEach(sv => {
    const p = data.phases[sv.i];
    if (p) { if (sv.start) p.start = sv.start; if (sv.end) p.end = sv.end; }
  });
  if (Array.isArray(state.order) && state.order.length === data.items.length) {
    const byKey = orderKeyMap(data.items, keys);
    const reordered = state.order.map(k => byKey.get(k)).filter(Boolean);
    // All or nothing. A single unresolved key would otherwise drop a row.
    if (reordered.length === data.items.length && new Set(reordered).size === data.items.length) {
      data.items = reordered;
    }
  }
  // The merged plan is the app's canonical output, so it speaks the canonical
  // vocabulary. Stored data is never touched: this normalises on the way OUT, so
  // /export/json, /export/csv, the SVG and the HTML snapshot all agree, and a
  // sidecar still saying "live" keeps saying it until the user next saves.
  data.items.forEach(r => { if (r.status) r.status = statusKey(r.status); });
  return data;
}

// ── CSV Parsing (RFC 4180 compliant) ──

function parseCSVRow(line) {
  const fields = [];
  let i = 0, field = '', inQuotes = false;
  while (i < line.length) {
    const ch = line[i];
    if (inQuotes) {
      if (ch === '"') {
        if (i + 1 < line.length && line[i + 1] === '"') {
          field += '"';
          i += 2;
        } else {
          inQuotes = false;
          i++;
        }
      } else {
        field += ch;
        i++;
      }
    } else {
      if (ch === '"') {
        inQuotes = true;
        i++;
      } else if (ch === ',') {
        fields.push(field.trim());
        field = '';
        i++;
      } else {
        field += ch;
        i++;
      }
    }
  }
  fields.push(field.trim());
  return fields;
}

function parseCSV(text) {
  const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter(l => l.trim());
  if (lines.length < 2) return [];
  const headers = parseCSVRow(lines[0]).map(h => h.toLowerCase().trim());
  const rows = [];
  for (let i = 1; i < lines.length; i++) {
    const vals = parseCSVRow(lines[i]);
    const obj = {};
    headers.forEach((h, j) => { obj[h] = csvUnguard(vals[j] || ''); });
    rows.push(obj);
  }
  return rows;
}

// A spreadsheet treats a cell opening with = + - @ (or a tab or CR) as a formula,
// so an owner or a note typed into the table would execute on open. Guard it with
// a leading apostrophe, which is the convention every spreadsheet understands.
// CSV import strips the guard back off, so the round trip is lossless.
const CSV_FORMULA_LEAD = /^[=+\-@\t\r]/;
function csvGuard(s) { return CSV_FORMULA_LEAD.test(s) ? "'" + s : s; }
function csvUnguard(s) { return (typeof s === 'string' && s.length > 1 && s[0] === "'" && CSV_FORMULA_LEAD.test(s.slice(1))) ? s.slice(1) : s; }

// `phases` is passed so the phase column can carry the phase NAME. It used to
// write the numeric index, which /import/csv reads as a phase name, so an export
// re-imported produced phases literally called "0".."5" with every row in phase 0.
function itemsToCSV(items, phases) {
  const headers = ['id', 'name', 'start', 'end', 'phase', 'owner', 'status', 'description', 'provenance', 'provenance_url', 'deps', 'type', 'label', 'date', 'striped'];
  const escape = v => {
    if (v == null) return '';
    const s = csvGuard(String(v));
    if (s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')) {
      return '"' + s.replace(/"/g, '""') + '"';
    }
    return s;
  };
  const phaseName = i => {
    const p = phases && phases[i];
    return p && p.name ? p.name : (i == null ? '' : i);
  };
  const lines = [headers.join(',')];
  items.forEach(item => {
    const row = headers.map(h => {
      if (h === 'deps') return escape((item.deps || []).join(';'));
      if (h === 'phase') return escape(phaseName(item.phase));
      // Two columns rather than one packed cell, so an export re-imported keeps
      // the label and the link as separate things.
      // Export the canonical vocabulary, so a CSV never carries the old words out.
      if (h === 'status') return escape(statusKey(item.status));
      if (h === 'provenance') { const p = item.provenance; return escape(typeof p === 'string' ? p : (p && p.label) || ''); }
      if (h === 'provenance_url') { const p = item.provenance; return escape((p && typeof p === 'object' && p.url) || ''); }
      return escape(item[h]);
    });
    lines.push(row.join(','));
  });
  return lines.join('\n');
}

// ── Import: JSON ──

function importJSON(body) {
  const data = JSON.parse(body);
  if (!data.project || !data.phases || !data.items) {
    throw new Error('JSON must contain project, phases, and items fields');
  }
  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);
  return { ok: true, items: data.items.length, phases: data.phases.length };
}

// ── Import: CSV ──

function importCSV(body, queryString) {
  const parsed = url_.parse('?' + (queryString || ''), true);
  const phasesParam = parsed.query.phases;

  const rows = parseCSV(body);
  if (rows.length === 0) throw new Error('CSV has no data rows');

  // Build items from CSV rows
  const items = [];
  const phaseNames = new Set();

  rows.forEach(row => {
    const phase = row.phase || 'Default';
    phaseNames.add(phase);
  });

  // Determine phases: from query param or auto-detect
  let phaseList;
  if (phasesParam) {
    phaseList = phasesParam.split(',').map(p => p.trim());
  } else {
    phaseList = Array.from(phaseNames);
  }

  const phaseColors = ['#64ABFF', '#3fb950', '#8C6BF7', '#f0883e', '#f85149', '#00b8b0', '#e6edf3', '#da3633'];
  const phases = phaseList.map((name, i) => ({
    name,
    color: phaseColors[i % phaseColors.length],
    start: '', end: ''
  }));

  // Build phase index
  const phaseIndex = {};
  phaseList.forEach((name, i) => { phaseIndex[name] = i; });

  // Track phase date ranges
  const phaseRanges = phaseList.map(() => ({ min: '9999-12-31', max: '0000-01-01' }));

  rows.forEach(row => {
    const phaseName = row.phase || 'Default';
    const pi = phaseIndex[phaseName] != null ? phaseIndex[phaseName] : 0;
    const deps = row.deps ? row.deps.split(';').map(d => d.trim()).filter(Boolean) : [];

    const item = {
      type: row.type || 'task',
      id: row.id || '',
      name: row.name || row.label || '',
      phase: pi,
    };

    if (row.start) { item.start = row.start; }
    if (row.end) { item.end = row.end; }
    if (row.date) { item.date = row.date; }
    if (row.owner) { item.owner = row.owner; }
    if (row.provenance || row.provenance_url) {
      item.provenance = row.provenance_url
        ? { label: row.provenance || row.provenance_url, url: row.provenance_url }
        : row.provenance;
    }
    // Import accepts either vocabulary and stores the canonical one.
    if (row.status) { item.status = statusKey(row.status); }
    if (row.description) { item.description = row.description; }
    if (deps.length) { item.deps = deps; }
    if (row.label) { item.label = row.label; }
    if (row.striped === 'true') { item.striped = true; }

    // Update phase date ranges
    const s = item.start || item.date;
    const e = item.end || item.date;
    if (s && s < phaseRanges[pi].min) phaseRanges[pi].min = s;
    if (e && e > phaseRanges[pi].max) phaseRanges[pi].max = e;

    items.push(item);
  });

  // Set phase start/end from discovered ranges
  phases.forEach((p, i) => {
    const r = phaseRanges[i];
    if (r.min !== '9999-12-31') {
      const [y, m] = r.min.split('-');
      p.start = `${y}-${m}`;
    }
    if (r.max !== '0000-01-01') {
      const [y, m] = r.max.split('-');
      p.end = `${y}-${m}`;
    }
  });

  const data = {
    project: { title: 'Imported Project', subtitle: 'CSV import', appName: 'adom-gantt' },
    phases,
    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 };
}

// ── Import: MS Project XML ──

function importMSProjectXML(body) {
  // Parse <Task> elements with regex
  const taskRegex = /<Task>([\s\S]*?)<\/Task>/g;
  const tasks = [];
  let match;

  while ((match = taskRegex.exec(body)) !== null) {
    const block = match[1];
    const get = tag => {
      const m = block.match(new RegExp(`<${tag}>(.*?)<\/${tag}>`));
      return m ? m[1].trim() : '';
    };

    const uid = get('UID');
    const name = get('Name');
    const start = get('Start');
    const finish = get('Finish');
    const pctComplete = parseInt(get('PercentComplete') || '0', 10);
    const summary = get('Summary');

    // Skip summary tasks (they are groups) unless they have no sub-tasks
    if (!name || summary === '1') continue;

    // Parse predecessors
    const predRegex = /<PredecessorLink>([\s\S]*?)<\/PredecessorLink>/g;
    const deps = [];
    let predMatch;
    while ((predMatch = predRegex.exec(block)) !== null) {
      const predUID = predMatch[1].match(/<PredecessorUID>(.*?)<\/PredecessorUID>/);
      if (predUID) deps.push('task-' + predUID[1]);
    }

    // Parse dates (MS Project uses ISO datetime, extract date portion)
    const parseDate = ds => {
      if (!ds) return '';
      const m = ds.match(/(\d{4}-\d{2}-\d{2})/);
      return m ? m[1] : '';
    };

    let status = 'not-started';
    if (pctComplete >= 100) status = 'live';
    else if (pctComplete > 0) status = 'planned';

    tasks.push({
      type: 'task',
      id: 'task-' + uid,
      name,
      start: parseDate(start),
      end: parseDate(finish),
      phase: 0,
      status,
      description: pctComplete > 0 ? `${pctComplete}% complete` : '',
      deps: deps.length ? deps : undefined
    });
  }

  if (tasks.length === 0) throw new Error('No tasks found in MS Project XML');

  // Compute date range for single phase
  let minDate = '9999-12-31', maxDate = '0000-01-01';
  tasks.forEach(t => {
    if (t.start && t.start < minDate) minDate = t.start;
    if (t.end && t.end > maxDate) maxDate = t.end;
  });

  const [minY, minM] = minDate.split('-');
  const [maxY, maxM] = maxDate.split('-');

  const data = {
    project: { title: 'MS Project Import', subtitle: 'Imported from MS Project XML', appName: 'adom-gantt' },
    phases: [{
      name: 'Imported',
      color: '#64ABFF',
      start: `${minY}-${minM}`,
      end: `${maxY}-${maxM}`
    }],
    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 };
}

// ── Import: JIRA CSV ──

function importJiraCSV(body) {
  const rows = parseCSV(body);
  if (rows.length === 0) throw new Error('JIRA CSV has no data rows');

  const statusMap = {
    'done': 'live',
    'closed': 'live',
    'resolved': 'live',
    'in progress': 'planned',
    'in review': 'planned',
    'to do': 'not-started',
    'open': 'not-started',
    'backlog': 'not-started',
  };

  const items = [];
  let minDate = '9999-12-31', maxDate = '0000-01-01';

  rows.forEach((row, i) => {
    // JIRA CSV columns: Summary, Issue key, Issue id, Issue Type, Status, Created, Updated, Due Date, etc.
    const name = row.summary || row.title || row.name || '';
    const key = row['issue key'] || row.key || row.id || `jira-${i}`;
    const status = (row.status || '').toLowerCase();
    const created = row.created || row['created date'] || '';
    const dueDate = row['due date'] || row.due || row.duedate || '';

    // Parse dates: JIRA uses various formats
    const parseJiraDate = ds => {
      if (!ds) return '';
      // Try ISO format first
      const isoMatch = ds.match(/(\d{4}-\d{2}-\d{2})/);
      if (isoMatch) return isoMatch[1];
      // Try dd/MMM/yy or dd/MMM/yyyy
      const slashMatch = ds.match(/(\d{1,2})\/([A-Za-z]{3})\/(\d{2,4})/);
      if (slashMatch) {
        const months = { jan: '01', feb: '02', mar: '03', apr: '04', may: '05', jun: '06', jul: '07', aug: '08', sep: '09', oct: '10', nov: '11', dec: '12' };
        const d = slashMatch[1].padStart(2, '0');
        const m = months[slashMatch[2].toLowerCase()] || '01';
        let y = slashMatch[3];
        if (y.length === 2) y = (parseInt(y) > 50 ? '19' : '20') + y;
        return `${y}-${m}-${d}`;
      }
      return '';
    };

    const startDate = parseJiraDate(created);
    const endDate = parseJiraDate(dueDate) || startDate;

    const mappedStatus = statusMap[status] || 'not-started';
    const description = row.description || '';

    if (startDate && startDate < minDate) minDate = startDate;
    if (endDate && endDate > maxDate) maxDate = endDate;
    if (startDate && startDate > maxDate) maxDate = startDate;

    items.push({
      type: 'task',
      id: key.replace(/\s+/g, '-').toLowerCase(),
      name,
      start: startDate || endDate,
      end: endDate || startDate,
      phase: 0,
      status: mappedStatus,
      description: description.length > 200 ? description.substring(0, 200) + '...' : description
    });
  });

  // Filter out items without any dates
  const validItems = items.filter(it => it.start && it.end);
  if (validItems.length === 0) throw new Error('No items with valid dates found in JIRA CSV');

  if (minDate === '9999-12-31') minDate = new Date().toISOString().slice(0, 10);
  if (maxDate === '0000-01-01') maxDate = new Date().toISOString().slice(0, 10);

  const [minY, minM] = minDate.split('-');
  const [maxY, maxM] = maxDate.split('-');

  const data = {
    project: { title: 'JIRA Import', subtitle: 'Imported from JIRA CSV', appName: 'adom-gantt' },
    phases: [{
      name: 'JIRA Issues',
      color: '#64ABFF',
      start: `${minY}-${minM}`,
      end: `${maxY}-${maxM}`
    }],
    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 };
}

// ── Export: SVG ──

function generateSVG() {
  const data = mergedData();
  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;
  const GROUP_H = 28;
  const HEADER_H = 60;
  const LABEL_W = 260;
  const PAD = 16;

  // Compute date range
  let minDate = '9999-12-31', maxDate = '0000-01-01';
  items.forEach(r => {
    if (r.start && r.start < minDate) minDate = r.start;
    if (r.end && r.end > maxDate) maxDate = r.end;
    if (r.date && r.date < minDate) minDate = r.date;
    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);
  const START = new Date(minY, minM - 1, 1);
  const MONTHS = (maxY - minY) * 12 + (maxM - minM) + 2;
  const timelineW = MONTHS * COL;
  const totalW = LABEL_W + timelineW + PAD * 2;

  function mDiff(a, b) { return (b.getFullYear() - a.getFullYear()) * 12 + b.getMonth() - a.getMonth(); }
  function toX(ds) {
    const [y, mo, d] = ds.split('-').map(Number);
    return LABEL_W + PAD + (mDiff(START, new Date(y, mo - 1, d || 1)) + ((d ? (d - 1) / 30 : 0))) * COL;
  }

  // Calculate total height
  let totalH = HEADER_H;
  items.forEach(r => { totalH += r.type === 'group' ? GROUP_H : ROW_H; });
  totalH += PAD;

  const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

  // "--" 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; }
    .surface { fill: #161b22; }
    .bar-label { fill: #e6edf3; font-family: sans-serif; font-size: 12px; }
    .group-label { fill: #8b949e; font-family: sans-serif; font-size: 12px; font-weight: 600; }
    .header-text { fill: #e6edf3; font-family: sans-serif; font-size: 16px; font-weight: 600; }
    .month-text { fill: #484f58; font-family: monospace; font-size: 10px; text-anchor: middle; }
    .status-text { font-family: monospace; font-size: 9px; }
    .ms-label { fill: #8b949e; font-family: sans-serif; font-size: 11px; font-style: italic; }
    .gridline { stroke: rgba(48,54,61,0.18); stroke-width: 1; }
    .border { stroke: #30363d; stroke-width: 1; fill: none; }
  </style>
</defs>
<rect class="bg" width="${totalW}" height="${totalH}"/>`;

  // Title
  svg += `\n<text class="header-text" x="${PAD}" y="28">${esc(project.title)}</text>`;
  svg += `\n<text class="month-text" x="${PAD}" y="42" style="text-anchor:start;fill:#8b949e;">${esc(project.subtitle || '')}</text>`;

  // Month headers
  let cm = new Date(START);
  const mNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
  for (let i = 0; i < MONTHS; i++) {
    const x = LABEL_W + PAD + i * COL + COL / 2;
    svg += `\n<text class="month-text" x="${x}" y="${HEADER_H - 8}">${mNames[cm.getMonth()]} ${cm.getFullYear()}</text>`;
    cm = new Date(cm.getFullYear(), cm.getMonth() + 1, 1);
  }

  // Grid lines
  for (let i = 0; i <= MONTHS; i++) {
    const x = LABEL_W + PAD + i * COL;
    svg += `\n<line class="gridline" x1="${x}" y1="${HEADER_H}" x2="${x}" y2="${totalH}"/>`;
  }

  // Phase backgrounds
  phases.forEach(p => {
    const [sy, sm] = p.start.split('-').map(Number);
    const [ey, em] = p.end.split('-').map(Number);
    const x1 = LABEL_W + PAD + mDiff(START, new Date(sy, sm - 1, 1)) * COL;
    const x2 = LABEL_W + PAD + (mDiff(START, new Date(ey, em - 1, 1)) + 1) * COL;
    svg += `\n<rect x="${x1}" y="${HEADER_H}" width="${x2 - x1}" height="${totalH - HEADER_H}" fill="${p.color}" opacity="0.04"/>`;
  });

  // Items
  let y = HEADER_H;
  items.forEach(r => {
    const h = r.type === 'group' ? GROUP_H : ROW_H;

    if (r.type === 'group') {
      svg += `\n<rect class="surface" x="0" y="${y}" width="${totalW}" height="${h}"/>`;
      svg += `\n<text class="group-label" x="${PAD}" y="${y + h / 2 + 4}">${esc(r.label)}</text>`;
    } else if (r.type === 'milestone') {
      const mx = toX(r.date);
      svg += `\n<text class="ms-label" x="${PAD}" y="${y + h / 2 + 4}">&#9670; ${esc(r.name)}</text>`;
      svg += `\n<rect x="${mx - 5}" y="${y + 12}" width="10" height="10" transform="rotate(45 ${mx} ${y + 17})" fill="#e6edf3" stroke="#0d1117" stroke-width="2"/>`;
    } else {
      const x1 = toX(r.start);
      const x2 = toX(r.end);
      const w = Math.max(x2 - x1, 6);
      const color = phases[r.phase] ? phases[r.phase].color : '#64ABFF';

      // Status tag
      const statusColors = { live: '#3fb950', planned: '#64ABFF', 'not-started': '#484f58' };
      const statusLabels = { live: 'live', planned: 'planned', 'not-started': 'not started' };
      const stag = r.status ? ` [${statusLabels[r.status] || r.status}]` : '';

      svg += `\n<text class="bar-label" x="${PAD}" y="${y + h / 2 + 4}">${esc(r.name)}</text>`;
      if (r.status) {
        const tagColor = statusColors[r.status] || '#484f58';
        const textLen = r.name.length * 7 + PAD;
        svg += `\n<text class="status-text" x="${textLen + 8}" y="${y + h / 2 + 4}" fill="${tagColor}">${esc(statusLabels[r.status] || r.status)}</text>`;
      }

      svg += `\n<rect x="${x1}" y="${y + 7}" width="${w}" height="20" rx="6" fill="${color}" opacity="0.82"/>`;

      if (r.striped) {
        svg += `\n<rect x="${x1}" y="${y + 7}" width="${w}" height="20" rx="6" fill="url(#stripes)" opacity="0.12"/>`;
      }
    }

    // Row separator
    svg += `\n<line x1="0" y1="${y + h}" x2="${totalW}" y2="${y + h}" stroke="rgba(48,54,61,0.15)" stroke-width="1"/>`;

    y += h;
  });

  // Today line
  const today = new Date();
  const tx = LABEL_W + PAD + mDiff(START, today) * COL + (today.getDate() / 30) * COL;
  if (tx > LABEL_W + PAD && tx < LABEL_W + PAD + timelineW) {
    svg += `\n<line x1="${tx}" y1="${HEADER_H}" x2="${tx}" y2="${totalH}" stroke="#00b8b0" stroke-width="2" opacity="0.8"/>`;
    svg += `\n<text x="${tx}" y="${HEADER_H + 14}" fill="#00b8b0" font-family="monospace" font-size="9" text-anchor="middle">today</text>`;
  }

  svg += '\n</svg>';
  return svg;
}

// ── Export: HTML snapshot ──

function generateHTMLSnapshot() {
  const ganttHtml = serveApp();
  const data = mergedData();

  // Replace the fetch('data') call with inline data.
  // The original code: const projData = await fetch('data').then(r => r.json());
  //
  // JSON.stringify does NOT escape "<", so a task note containing "</script>"
  // closed this tag and everything after it ran as markup. Escaping every "<" as
  // < is still valid JS and still parses back to the same string.
  const inlineDataScript = `const projData = ${JSON.stringify(data).replace(/</g, '\\u003c')};`;

  // Also replace the state fetch with a no-op
  // The original: const r=await fetch('state');if(r.ok){const st=await r.json();...
  // We set state to already-applied (merged data already has state applied)
  const inlineStateScript = `const r={ok:true,json:async()=>({empty:true})};`;

  let html = ganttHtml;

  // Replace the data fetch line
  html = html.replace(
    /const projData = await fetch\('data'\)\.then\(r => r\.json\(\)\);/,
    inlineDataScript
  );

  // Replace the state fetch block
  html = html.replace(
    /try\{const r=await fetch\('state'\);if\(r\.ok\)\{const st=await r\.json\(\);if\(!st\.empty\)\{applyState\(st\);Object\.keys\(barEls\)\.forEach\(id=>updateBar\(id\)\);\}\}\}catch\(e\)\{\}/,
    '/* state already merged into inline data */'
  );

  // saveToFile and the changelog POST both check SNAPSHOT themselves (set below),
  // which is why there is no longer a regex rewriting the function body.

  // Read and inline favicon
  const faviconPath = path.join(APP_DIR, 'favicon.svg');
  if (fs.existsSync(faviconPath)) {
    const faviconData = fs.readFileSync(faviconPath, 'utf8');
    const b64 = Buffer.from(faviconData).toString('base64');
    html = html.replace(
      '<link rel="icon" href="favicon.svg">',
      `<link rel="icon" href="data:image/svg+xml;base64,${b64}">`
    );
  }

  // A snapshot must open at the view its author curated, not at whatever the viewer
  // has in localStorage from some other chart. project.view supplies the defaults.
  html = html.replace(/const SNAPSHOT = false;/, 'const SNAPSHOT = true;');

  // Add a comment at the top indicating this is a snapshot. "--" cannot appear
  // inside an HTML comment, so squash any that the view object carries.
  const v = (data.project && data.project.view) || {};
  const vTxt = Object.keys(v).length ? ', view ' + JSON.stringify(v).replace(/-{2,}/g, '-').replace(/>/g, '') : '';
  html = html.replace('<!DOCTYPE html>', `<!DOCTYPE html>\n<!-- adom-gantt snapshot, generated ${new Date().toISOString()}${vTxt} -->`);

  return html;
}

// ── Server ──

const server = http.createServer(async (req, res) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
  if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }

  const parsed = url_.parse(req.url, true);
  const urlPath = parsed.pathname;
  const query = parsed.search ? parsed.search.slice(1) : '';

  try {

    // ── Existing endpoints ──

    if (urlPath === '/data') {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(fs.readFileSync(DATA_FILE, 'utf8'));
      return;
    }

    if (req.method === 'POST' && urlPath === '/save') {
      const body = await readBody(req);
      try {
        JSON.parse(body);
        fs.writeFileSync(STATE_FILE, body, 'utf8');
        jsonResp(res, 200, { ok: true, file: path.basename(STATE_FILE), bytes: body.length });
      } catch (e) {
        jsonResp(res, 400, { ok: false, error: e.message });
      }
      return;
    }

    // ── Changelog ──

    if (req.method === 'POST' && urlPath === '/log') {
      const body = await readBody(req);
      try {
        const payload = JSON.parse(body);
        // The log is append-only, so anything accepted here is permanent. Say no
        // to a shape that cannot mean anything rather than storing it forever:
        // valid JSON of the wrong shape used to come back 200 and silently vanish.
        let events;
        if (Array.isArray(payload)) events = payload;
        else if (payload && typeof payload === 'object') {
          if (payload.events === undefined) events = [];
          else if (!Array.isArray(payload.events)) {
            jsonResp(res, 400, { ok: false, error: '"events" must be an array', rev: currentRev() });
            return;
          } else events = payload.events;
        } else {
          jsonResp(res, 400, { ok: false, error: 'body must be an events array or an object with one', rev: currentRev() });
          return;
        }
        const bad = events.filter(e => !e || typeof e !== 'object' || Array.isArray(e) || typeof e.action !== 'string' || !e.action);
        if (bad.length) {
          jsonResp(res, 400, { ok: false, error: 'every event needs a string "action"', rejected: bad.length, rev: currentRev() });
          return;
        }
        if (!events.length) { jsonResp(res, 200, { ok: true, added: 0, rev: currentRev() }); return; }
        const out = appendLog(events, payload && payload.actor);
        jsonResp(res, 200, { ok: true, added: out.added, rev: out.rev, file: path.basename(LOG_FILE) });
      } catch (e) {
        jsonResp(res, 400, { ok: false, error: e.message });
      }
      return;
    }

    if (urlPath === '/changelog') {
      const since = parseInt(parsed.query.since, 10) || 0;
      const all = readLog();
      const rev = all.length ? all[all.length - 1].rev : 0;
      const sel = since ? all.filter(e => e.rev > since) : all;
      if (parsed.query.format === 'md') {
        // Render BEFORE the head goes out. Otherwise a throw in here lands in the
        // outer catch, which writes a second head, and ERR_HTTP_HEADERS_SENT takes
        // the whole process down.
        const md = changelogMarkdown(sel, since, rev);
        res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
        res.end(md);
      } else {
        jsonResp(res, 200, {
          rev,
          since,
          count: sel.length,
          touched: [...new Set(sel.filter(e => e.id).map(e => e.id))],
          events: sel,
        });
      }
      return;
    }

    if (req.method === 'POST' && urlPath === '/changelog/reset') {
      if (fs.existsSync(LOG_FILE)) fs.unlinkSync(LOG_FILE);
      jsonResp(res, 200, { ok: true, rev: 0 });
      return;
    }

    if (urlPath === '/state') {
      if (fs.existsSync(STATE_FILE)) {
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(fs.readFileSync(STATE_FILE, 'utf8'));
      } else {
        jsonResp(res, 200, { empty: true });
      }
      return;
    }

    // ── Import endpoints ──

    if (req.method === 'POST' && urlPath === '/compact') {
      try {
        const state = loadState();
        const merged = mergedData();
        const info = {
          items: merged.items.length,
          adds: (state && state.added || []).length,
          deletes: (state && state.deleted || []).length,
        };
        fs.writeFileSync(DATA_FILE, JSON.stringify(merged, null, 2) + '\n', 'utf8');
        if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);
        logCompact(info);
        jsonResp(res, 200, Object.assign({ ok: true, file: path.basename(DATA_FILE) }, info));
      } catch (e) {
        jsonResp(res, 400, { ok: false, error: e.message });
      }
      return;
    }

    if (req.method === 'POST' && urlPath === '/import/json') {
      const body = await readBody(req);
      try {
        const result = importJSON(body);
        logImport('json', result);
        jsonResp(res, 200, result);
      } catch (e) {
        jsonResp(res, 400, { ok: false, error: e.message });
      }
      return;
    }

    if (req.method === 'POST' && urlPath === '/import/csv') {
      const body = await readBody(req);
      try {
        const result = importCSV(body, query);
        logImport('csv', result);
        jsonResp(res, 200, result);
      } catch (e) {
        jsonResp(res, 400, { ok: false, error: e.message });
      }
      return;
    }

    if (req.method === 'POST' && urlPath === '/import/msproject-xml') {
      const body = await readBody(req);
      try {
        const result = importMSProjectXML(body);
        logImport('msproject-xml', result);
        jsonResp(res, 200, result);
      } catch (e) {
        jsonResp(res, 400, { ok: false, error: e.message });
      }
      return;
    }

    if (req.method === 'POST' && urlPath === '/import/jira-csv') {
      const body = await readBody(req);
      try {
        const result = importJiraCSV(body);
        logImport('jira-csv', result);
        jsonResp(res, 200, result);
      } catch (e) {
        jsonResp(res, 400, { ok: false, error: e.message });
      }
      return;
    }

    // ── Export endpoints ──

    if (urlPath === '/export/json') {
      const data = mergedData();
      res.writeHead(200, {
        'Content-Type': 'application/json',
        'Content-Disposition': 'attachment; filename="adom-gantt-export.json"'
      });
      res.end(JSON.stringify(data, null, 2));
      return;
    }

    if (urlPath === '/export/csv') {
      const data = mergedData();
      const csv = itemsToCSV(data.items, data.phases);
      res.writeHead(200, {
        'Content-Type': 'text/csv',
        'Content-Disposition': 'attachment; filename="adom-gantt-export.csv"'
      });
      res.end(csv);
      return;
    }

    if (urlPath === '/export/svg') {
      const svg = generateSVG();
      res.writeHead(200, {
        'Content-Type': 'image/svg+xml',
        'Content-Disposition': 'attachment; filename="adom-gantt-timeline.svg"'
      });
      res.end(svg);
      return;
    }

    if (urlPath === '/export/html') {
      const html = generateHTMLSnapshot();
      res.writeHead(200, {
        'Content-Type': 'text/html',
        'Content-Disposition': 'attachment; filename="adom-gantt-snapshot.html"'
      });
      res.end(html);
      return;
    }

    // ── Static file serving ──

    if (urlPath === '/' || urlPath === '/gantt.html') {
      res.writeHead(200, { 'Content-Type': 'text/html' });
      res.end(serveApp());
      return;
    }

    let filePath = path.join(APP_DIR, urlPath === '/' ? 'gantt.html' : urlPath);
    const ext = path.extname(filePath);
    if (!fs.existsSync(filePath)) { res.writeHead(404); res.end('Not found'); return; }
    res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
    fs.createReadStream(filePath).pipe(res);

  } catch (e) {
    // Once a head is out, writing a second one throws ERR_HTTP_HEADERS_SENT from
    // outside any catch and kills the process. Drop the connection instead: one
    // bad request must never be able to take the server with it.
    if (res.headersSent) { try { res.destroy(); } catch (_) {} return; }
    jsonResp(res, 500, { ok: false, error: e.message });
  }
});

// Last line of defence. A single malformed row in an append-only log should cost
// one request, not the process and every future restart.
process.on('uncaughtException', err => {
  console.error('adom-gantt: uncaught', err && err.stack ? err.stack : err);
});

server.listen(PORT, '127.0.0.1', () => {
  console.log(`adom-gantt serving ${path.basename(DATA_FILE)} on http://127.0.0.1:${PORT}`);
  console.log(`State file: ${STATE_FILE}`);
  console.log(`Data dir: ${DATA_DIR}`);
  console.log(`Endpoints: /data /state /save /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);
});