app
Adom Gantt
Public Made by Adomby adom
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
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
// "--" 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}">◆ ${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}`);
});