app
Adom Gantt
Public Made by Adomby adom
Interactive Gantt chart viewer for project timelines, with a table view and an analytics page over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. One click auto-schedules the whole plan so every dependency lines up finish-to-start on working days, skipping weekends and US federal holidays, push
← Commit history
Table view: owner, deadline and status as a second lens over the same plan
Adds a Gantt / Table toggle in the header. The table is one row per task and
per milestone with Task, Phase, Owner, Start, Deadline, Status and Notes:
sortable on every column (deadline ascending by default), filterable by owner,
status and free text, and editable in place.
Every table edit runs through the existing pipeline. It mutates the item,
scheduleSaves to the sidecar, appends to the changelog with via: table-edit,
shows on the chart the moment you toggle back, and is one Ctrl+Z.
owner is now a first-class item field on tasks and milestones. It round-trips
through CSV import and both CSV and JSON export, and owner, status and desc
changes get their own changelog actions.
The sidecar moves to v3, additively: v1 bare arrays and v2 objects still load,
and an absent field means the data file wins while an empty one means cleared.
Also fixes two pre-existing bugs. The HTML snapshot export tried to disable
saving with a regex that could never match its own source, so a snapshot still
POSTed to a server that was not there; it now checks the SNAPSHOT flag the
exporter already sets. And two em dashes in visible copy are gone.
7 files changed
+662−57
README.md+11−4@@ -4,6 +4,10 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau ## Features +**Two lenses over one plan.** A **Gantt / Table** toggle in the header. The chart answers "when". The table answers "whose, and by when": every task and every milestone as a row with Owner, Start, Deadline, Status and Notes, sortable on any column, filtered to one person with the owner dropdown, and editable in place. An edit made in the table is already true on the chart when you toggle back, because there is only one set of items underneath. The choice of lens persists, and `project.view.mode` can pin it for a plan that is really an assignment list.++**Owner is a first-class field.** Put `"owner": "Adonis"` on a task or a milestone: one name, never a team. It drives the table's owner filter, it round-trips through CSV and JSON export, and changing it is recorded in the changelog like any other edit.+ **Visual timeline.** Color-coded phase bars, diamond milestones, dependency arrows, and a live "today" marker. Date range and month count auto-computed from data. **Click-to-edit.** Click any bar to open an inline editor with date pickers and dependency management. Drag bar edges to resize start/end dates. Drag bar body to slide dates while preserving duration.@@ -16,13 +20,13 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **Legend filtering.** Click any phase, "Milestone", "Dependencies" or "Today" in the legend bar to toggle visibility. Dimmed items fade to 15% opacity; hiding "Today" removes the marker line. -**Undo.** Ctrl+Z undoes date edits, dependency changes, drag operations, and row reorders (100-level stack).+**Undo.** Ctrl+Z undoes date edits, dependency changes, drag operations, row reorders, and table edits to owner, status and notes (100-level stack). **Import.** JSON (native format), CSV, MS Project XML, and Jira CSV. Drag-and-drop or file picker. **Export.** JSON, CSV, SVG, and self-contained HTML (no server needed to view the snapshot). -**Auto-save.** Edits persist to a sidecar JSON file next to the data file. Delete the sidecar to reset.+**Auto-save.** Edits persist to a sidecar JSON file next to the data file. Delete the sidecar to reset. The format is additive: a sidecar written by an older version still loads. **Fills the window.** Cells stretch to the width of the pane, and stop at each grain's minimum so a long plan scrolls horizontally rather than squashing into unreadability. Resizing reflows without a reload. @@ -44,7 +48,7 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark by defau **Clean group labels.** A trailing date range in a group label is dropped at render, since the axis already shows the dates and the text goes stale as soon as the plan moves. -**Authored default view.** `project.view` in the data file pins how the chart opens: grain, milestone style, today marker, theme and name-column width. The HTML export bakes it in and ignores the viewer's saved preferences, so a shared snapshot always matches the image that invited the click. In the live app your own choices still win.+**Authored default view.** `project.view` in the data file pins how the chart opens: lens (`mode`), grain, milestone style, today marker, theme and name-column width. The HTML export bakes it in and ignores the viewer's saved preferences, so a shared snapshot always matches the image that invited the click. In the live app your own choices still win. **Changelog.** Every edit is appended to `<datafile>-changelog.jsonl` with the item, the before and after dates, the day shift, and which gesture made it. An AI picking the project up later reads it and builds on your changes instead of overwriting them. The rev counter in the bottom-left corner is the handle: an agent notes it, then asks for only what changed since. @@ -81,7 +85,7 @@ Create a JSON file with three sections: ], "items": [ { "type": "group", "label": "Phase 1 — Build", "phase": 0 },- { "type": "task", "id": "t1", "name": "Task name", "start": "2026-01-15", "end": "2026-03-01", "phase": 0, "status": "planned", "description": "Details.", "deps": ["other-id"] },+ { "type": "task", "id": "t1", "name": "Task name", "start": "2026-01-15", "end": "2026-03-01", "phase": 0, "owner": "Adonis", "status": "planned", "description": "Details.", "deps": ["other-id"] }, { "type": "milestone", "name": "M1: Goal reached", "date": "2026-06-01", "phase": 0, "description": "What this means." } ] }@@ -102,6 +106,9 @@ Omit `color` on a phase and it follows the Adom brand ramp, which adapts between | Gesture | Action | |---------|--------|+| Gantt / Table | Switch lens |+| Click a table cell | Edit owner, start, deadline, status or notes in place |+| Click a table header | Sort by that column (deadline ascending by default) | | Click bar or label | Select + open editor | | Drag bar edge | Resize start or end date | | Drag bar body | Move bar (preserves duration) |
SKILL.md+70−13@@ -5,9 +5,12 @@ description: > data-driven timeline from a JSON file with phases, tasks, milestones, and dependency arrows. Features: click-to-filter legend, drag-to-resize bars, inline date editor, dependency management, undo (Ctrl+Z), and- auto-save to a sidecar state file. Trigger words: gantt, timeline,- project schedule, gantt chart, project gantt, show timeline, view- schedule, project plan chart, milestone chart.+ auto-save to a sidecar state file. A Table view is the second lens over+ the same data: every task and milestone as a row with Owner, Deadline+ and Status, filterable per owner and editable in place. Trigger words:+ gantt, timeline, project schedule, gantt chart, project gantt, show+ timeline, view schedule, project plan chart, milestone chart, task+ table, who owns what, what is mine, owner, deadline, task list. --- # adom-gantt@@ -86,11 +89,12 @@ preference the user has set for themselves still wins; an exported snapshot igno preferences entirely so it always opens as authored. ```json-"view": { "grain": "quarter", "milestones": "lines", "today": false, "labelWidth": 320 }+"view": { "mode": "table", "grain": "quarter", "milestones": "lines", "today": false, "labelWidth": 320 } ``` | Field | Values | Description | |-------|--------|-------------|+| mode | `gantt` `table` | Which lens the plan opens in | | grain | `day` `week` `month` `quarter` `year` | Cell size | | milestones | `lines` `diamonds` | Milestone style | | today | `true` `false` | Show the today marker |@@ -131,8 +135,9 @@ Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`. | start | YYYY-MM-DD | yes | Start date | | end | YYYY-MM-DD | yes | End date | | phase | number | yes | Index into phases[] |+| owner | string | no | The one person accountable. A single name, never a team. Drives the table's owner filter | | status | string | no | One of: "live", "planned", "not-started" |-| description | string | no | Tooltip body text |+| description | string | no | Tooltip body text, and the Notes column in the table | | deps | string[] | no | Array of task IDs this depends on | | note | string | no | Short label drawn on the bar itself, e.g. `6m` or `Y1`. Keep it to a few characters | | striped | boolean | no | Striped bar pattern (for ongoing work) |@@ -142,9 +147,11 @@ Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`. | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | yes | Milestone label |-| date | YYYY-MM-DD | yes | Milestone date |+| date | YYYY-MM-DD | yes | Milestone date, and the Deadline column in the table | | phase | number | yes | Index into phases[] |-| description | string | no | Tooltip body text |+| owner | string | no | Usually absent. The table shows "unassigned" and lets you set one |+| status | string | no | Usually absent. Same values as a task |+| description | string | no | Tooltip body text, and the Notes column in the table | ### Phase colors @@ -176,6 +183,7 @@ rather than a category. ## Features +- **Gantt / Table toggle** in the header. Two lenses over the same items: the chart answers "when", the table answers "whose, and by when". See The table view below - **Click legend** to filter phases, milestones, dependency arrows, or the today line - **Click a bar** to open the date editor with start/end pickers and dependency management - **Drag bar edges** to resize (changes start or end date)@@ -196,6 +204,43 @@ View preferences (theme, milestone style, cell size, today marker, name column w `localStorage` per browser. They are a view concern, so they are not written to the data file. The project start is NOT a view preference: it edits real dates. +## The table view++The header carries a **Gantt / Table** toggle. The table is the same items in a+different shape: one row per task and per milestone, no group rows, with the phase+named in its own column.++| Column | Task | Milestone |+|--------|------|-----------|+| Task | `name` | `name`, marked with a diamond |+| Phase | `phases[phase].name`, with the phase colour | same |+| Owner | `owner`, or "unassigned" | usually "unassigned" |+| Start | `start` | blank, a milestone is a point |+| Deadline | `end` | `date` |+| Status | `status`, or "none" | `status`, or "none" |+| Notes | `description`, truncated | same |++**Sorting.** Click any header. Default is deadline ascending. Blank values sort last+in both directions, so an unassigned row never displaces what you are looking for.++**Filtering.** The owner dropdown is built from the distinct `owner` values in the+data, plus "All owners" and "Unassigned"; it answers "what is mine". There is also a+status dropdown and a free-text filter over name, owner, phase, notes and status. The+legend's phase and milestone toggles apply to the table as well, so the two lenses+never disagree about what is in scope. The owner filter and the sort survive a reload.++**Editing.** Click a cell to edit Owner, Start, Deadline, Status or Notes in place.+Enter commits, Escape cancels, clicking away commits. Every edit mutates the item,+lands in the sidecar, appends to the changelog with `via: "table-edit"`, shows on the+chart the moment you toggle back, and is one Ctrl+Z. Pulling a deadline in front of a+start drags the start with it rather than drawing a backwards bar.++New changelog actions: `owner`, `status`, `desc`. Milestone versions of these carry+`ms: true` and no `id`, since milestones are keyed by name.++`project.view.mode` pins which lens a plan opens in, the same way `grain` pins the+cell size. Use it when the plan is really an assignment list.+ ## Read the changelog before you edit a plan Every edit a human makes in the browser is appended to `<datafile>-changelog.jsonl`.@@ -210,7 +255,11 @@ curl -s localhost:8901/changelog # JSON: {rev, count, touch Each event records the action, the item, the before and after dates, the day shift, and `via` (which gesture made it: `editor`, `drag-move`, `drag-resize`, `drag-reorder`,-`batch-editor`, `bar-note`, `start-picker`, `start-revert`, `undo`).+`batch-editor`, `bar-note`, `table-edit`, `milestone-editor`, `start-picker`,+`start-revert`, `undo`).++Actions are `dates`, `plan.shift`, `milestone`, `note`, `owner`, `status`, `desc`,+`dep.add`, `dep.remove` and `reorder`. Moving the project start collapses into a single `plan.shift` event rather than one row per task, so the log says what happened instead of burying it in 26 identical lines.@@ -243,11 +292,19 @@ month abbreviation and carries a four-digit year, so `(EVT)` and `(Year 1-2)` ar ## State file -Edits (date changes, dependency adds/removes, notes, milestone dates, phase ranges and row-order) are saved to a sidecar JSON file next to your data file. For example, `smt-line.json` saves state to-`smt-line-state.json`. This file only contains deltas. It records the-current start, end, and deps for each task. Delete it to reset to the-original data file values.+Edits (date changes, dependency adds/removes, notes, owners, statuses, descriptions,+milestone dates, phase ranges and row order) are saved to a sidecar JSON file next to+your data file. For example, `smt-line.json` saves state to `smt-line-state.json`.+This file only contains deltas. Delete it to reset to the original data file values.++The sidecar is at `v: 3`. Task entries are+`{id, s, e, deps, note, owner, status, desc}` and milestone entries are+`{i, name, date, owner, status, desc}`. The `owner`, `status` and `desc` fields are+additive: a `v: 2` sidecar written before the table view existed still loads, and so+does the original `v: 1` bare task array. An absent field means the sidecar has+nothing to say and the data file's value stands; an empty one means the user cleared+it. `GET /state` returns this file verbatim, so it is the fastest way for an AI to+read the current owner and deadline of every item. ## Example
docs/BUILD-NOTES.mdadded+91@@ -0,0 +1,91 @@+# adom-gantt build notes++Decisions taken while building, and the places where the plan and the code+disagreed. Newest first.++## Build notes, 2026-08-07++Shipped the **table view** (0.8.0): a second lens over the same items, with owner,+deadline and status editable in place, flowing through the existing sidecar and+changelog pipeline.++### Where the build deviated from the plan++**The legend stays visible in table view, and filters it.** The plan implied the+table would carry its own filters alone. Hiding the legend would have meant a user+in the table could not narrow to a phase, and keeping it but ignoring it would have+meant the two lenses disagreed about what is in scope. So `applyFilter` now+re-renders the table, and the phase and Milestone toggles apply to both. The+Dependencies and Today toggles are chart-only and are hidden in table view+(`.legend-item.gantt-only`), as are the project-start control, the grain picker and+the milestone-style button: nothing in the table changes when they do.++**Name is not editable in the table.** The plan asked for owner, status, deadline+and notes; it did not ask for name, and name is load-bearing. Milestones carry no+`id` and are keyed by `name` in the sidecar, so renaming one from a bulk-edit+surface would silently orphan its saved state. Names stay editable where they+already were, in the milestone editor.++**Start is editable too.** It was the same machinery as the deadline and the column+was already there, so leaving it read-only would have been an arbitrary gap.++**Undo covers table edits.** The plan allowed skipping this if the undo stack was+not generic. It nearly was: `snapshotMany` and `snapshotMs` already captured a+per-item record, so they were widened to carry `owner`, `status` and `description`+and the restore paths widened to match. Milestone undo now also restores `name`,+which closes a pre-existing gap where the milestone editor could rename a milestone+and Ctrl+Z would put the date back but not the name.++**Group rows are not table rows.** A flat table cannot both sort globally and keep+section headers meaningful. Groups are dropped and the phase is named in its own+column instead, with the phase colour as a dot, so the grouping information+survives in a form that sorting cannot break.++**Blank values sort last in both directions.** Straight lexical sort puts every+unassigned row at the top of an owner sort, which is exactly where it is most in+the way.++**Sidecar bumped to `v: 3`, additively.** New fields are `owner`, `status`, `desc`+on task entries and on milestone entries. Verified: a `v: 1` bare task array and a+`v: 2` object sidecar both still load and apply. An absent field means the sidecar+has nothing to say and the data file wins; an empty one means the user cleared it,+which is why both the client and the server use a `setOpt` helper rather than a+plain assignment.++**Tasks with no `id` render read-only.** The rest of the app already assumes every+task has an id (`barEls` is keyed by it, and an id-less task would collide on+`barEls[undefined]`), so the table shows such rows but does not offer to edit them+rather than pretending to persist an edit it cannot key.++### Two pre-existing bugs fixed in passing++**The HTML snapshot never disabled saving.** `generateHTMLSnapshot` tried to+replace the client's `saveToFile` with a regex, `/function saveToFile\(\) \{[^}]+\}/`,+which could never match: the source has no space in `saveToFile(){`, and `[^}]+`+would have stopped at the first nested brace and produced broken JavaScript if it+had. So an exported snapshot still POSTed to a `save` endpoint that is not there.+Replaced with a check on the `SNAPSHOT` flag the exporter already sets, inside+`saveToFile` and inside the changelog POST. The regex is gone.++**Two em dashes in user-visible copy** (the document title and the batch editor's+hint) were replaced, per the Adom brand rule.++### Verification++Driven headless against a scratch instance: view toggle, 67 rows from the DC demo+plan, deadline-ascending default, owner sort, owner filter (Noah: 8 of 67),+Unassigned filter (the 9 milestones), text filter, in-place edits of owner, status,+deadline and notes on a task, owner and deadline on a milestone, then back to the+chart to confirm the bar had moved, the status tag had changed, the milestone caps+still laid out and the 60 dependency arrows still drew. Ctrl+Z put the milestone+date back. `GET /state` returned `v: 3` with the new fields; `GET /changelog`+returned `owner`, `status`, `desc`, `dates` and `milestone` events tagged+`via: "table-edit"`, and the markdown rendering read as sentences.++### Known gaps++- The `Import` paths other than CSV do not carry `owner`. MS Project XML and Jira+ CSV have no obvious single-owner field to map, and guessing one would put a wrong+ name against someone's work. CSV import and both export paths do carry it.+- The table does not support multi-select or batch edit. The chart does, and the+ batch editor is date-only; a batch reassign is the obvious next thing to add.
gantt.html+432−23@@ -255,6 +255,64 @@ body.col-resizing { cursor: col-resize; user-select: none; } .sel-count { position: fixed; bottom: 16px; left: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--accent); z-index: 99998; opacity: 0; transition: opacity 0.15s; } .sel-count.show { opacity: 1; } +/* ── View toggle: the same plan as a chart or as a table ── */+.view-toggle { display: flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; flex-shrink: 0; }+.view-toggle .vt-btn { background: none; border: none; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 11px; cursor: pointer; transition: background 0.15s, color 0.15s; }+.view-toggle .vt-btn:hover { background: rgba(255,255,255,0.04); color: var(--text); }+.view-toggle .vt-btn.on { background: var(--accent); color: var(--on-accent); }++/* ── Table view ──+ A second lens over the same items. The chart answers "when"; the table answers+ "whose, and by when". Both read and write the same rows, so an edit in one is+ already true in the other. */+.table-view { display: none; flex: 1; min-height: 0; flex-direction: column; background: var(--bg); }+:root[data-view="table"] .table-view { display: flex; }+:root[data-view="table"] .chart-wrap { display: none; }+/* The start control, the grain picker and the milestone style are chart chrome:+ nothing in the table changes when they do. */+:root[data-view="table"] header .start-ctl,+:root[data-view="table"] #btnMsStyle,+:root[data-view="table"] .legend-item.gantt-only { display: none; }+.table-tools { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border-bottom: 1px solid var(--border-muted); background: var(--surface); flex-shrink: 0; flex-wrap: wrap; }+.table-tools label { font-family: 'Satoshi', sans-serif; font-size: 11px; color: var(--text2); }+.table-tools select, .table-tools input[type="search"] { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 8px; outline: none; }+.table-tools select:hover, .table-tools select:focus,+.table-tools input[type="search"]:hover, .table-tools input[type="search"]:focus { border-color: var(--accent); color: var(--text); }+.table-tools input[type="search"] { width: 190px; }+.table-tools .tt-btn { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 10px; cursor: pointer; transition: background 0.15s, color 0.15s; }+.table-tools .tt-btn:hover { background: rgba(255,255,255,0.04); color: var(--text); }+.table-count { margin-left: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); }+/* The table scrolls inside its own box, so a wide plan never pushes the page sideways. */+.table-scroll { flex: 1; min-height: 0; overflow: auto; }+table.data-table { border-collapse: separate; border-spacing: 0; width: 100%; min-width: 900px; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text-body); }+.data-table thead th { position: sticky; top: 0; z-index: 2; background: var(--surface); border-bottom: 1px solid var(--border); font-family: 'Familjen Grotesk', sans-serif; font-size: 11px; font-weight: 600; color: var(--text2); text-align: left; padding: 8px 10px; white-space: nowrap; user-select: none; }+.data-table thead th.sortable { cursor: pointer; }+.data-table thead th.sortable:hover { color: var(--text); }+.data-table thead th.sorted { color: var(--accent); }+.data-table thead th .sort-caret { font-size: 8px; margin-left: 5px; }+.data-table tbody td { border-bottom: 1px solid rgba(48,54,61,0.15); padding: 5px 10px; vertical-align: middle; }+.data-table tbody tr:hover { background: var(--row-hover); }+.data-table td.c-name { color: var(--text); }+.data-table tr.is-ms td.c-name { color: var(--text2); font-style: italic; }+.data-table td.c-name .ms-mark { color: var(--text2); font-style: normal; margin-right: 4px; }+.data-table td.c-owner, .data-table td.c-date { font-family: 'JetBrains Mono', monospace; font-size: 11px; white-space: nowrap; }+.data-table td.c-phase { color: var(--text-body); white-space: nowrap; }+.data-table td.editable { cursor: text; }+.data-table td.editable:hover { box-shadow: inset 0 0 0 1px var(--border); border-radius: 4px; }+.data-table td.empty { color: var(--text2); }+.data-table .trunc { display: block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }+.data-table td.c-notes .trunc { max-width: 300px; color: var(--text-body); }+.data-table td.c-notes.empty .trunc { color: var(--text2); }+.data-table .phase-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 7px; vertical-align: middle; }+.data-table .cell-input { width: 100%; min-width: 90px; background: var(--bg); border: 1px solid var(--accent); border-radius: 4px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 3px 6px; outline: none; }+.data-table select.cell-input { font-family: 'Satoshi', sans-serif; }+.data-table .table-empty { padding: 22px 12px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 12px; }+@media print {+ .table-scroll { overflow: visible !important; }+ .table-tools { display: none !important; }+ table.data-table { min-width: 0; }+}+ .drop-indicator { position: absolute; left: 0; right: 0; height: 2px; background: var(--accent); z-index: 20; pointer-events: none; border-radius: 1px; box-shadow: 0 0 6px rgba(0,184,176,0.4); display: none; } .drop-indicator.show { display: block; } .label-row.dragging, .bar-row.dragging { opacity: 0.4; }@@ -273,7 +331,38 @@ input[type="file"] { display: none; } <div class="col-resizer" id="colResizer" title="Drag to resize the name column. Double-click to fit the longest name."></div> <div class="timeline-col" id="timeline"></div> </div>+ <div class="table-view" id="tableView">+ <div class="table-tools">+ <label for="tblOwner">owner</label>+ <select id="tblOwner" title="Show only one person's work"></select>+ <label for="tblStatus">status</label>+ <select id="tblStatus">+ <option value="">All</option>+ <option value="live">live</option>+ <option value="planned">planned</option>+ <option value="not-started">not started</option>+ </select>+ <input type="search" id="tblSearch" placeholder="Filter by text" aria-label="Filter rows by text">+ <button class="tt-btn" id="tblClear" title="Clear every filter">Clear</button>+ <span class="table-count" id="tblCount"></span>+ </div>+ <div class="table-scroll">+ <table class="data-table">+ <thead><tr id="tableHead">+ <th class="sortable" data-sort="name">Task</th>+ <th class="sortable" data-sort="phase">Phase</th>+ <th class="sortable" data-sort="owner">Owner</th>+ <th class="sortable" data-sort="start">Start</th>+ <th class="sortable" data-sort="deadline">Deadline</th>+ <th class="sortable" data-sort="status">Status</th>+ <th class="sortable" data-sort="notes">Notes</th>+ </tr></thead>+ <tbody id="tableBody"></tbody>+ </table>+ </div>+ </div> </div>+<datalist id="ownerList"></datalist> <div class="tip" id="tip"></div> <div class="editor" id="editor"></div> <div class="dropdown" id="exportMenu"></div>@@ -308,7 +397,7 @@ const importFileEl = $('importFile'); // ── Load data ── const projData = await fetch('data').then(r => r.json()); const { project, phases, items } = projData;-document.title = `${project.title} — adom-gantt`;+document.title = `${project.title} · adom-gantt`; // ── View preferences (theme, milestone style, start-date override) ── const PREF_KEY = 'adom-gantt:prefs';@@ -327,6 +416,9 @@ function viewDefaults(){ if (v.today !== undefined) d.showToday = !!v.today; if (v.theme === 'light' || v.theme === 'dark') d.theme = v.theme; if (v.labelWidth) d.labelW = Math.max(120, Math.min(760, +v.labelWidth || 0)) || undefined;+ // Which lens the plan opens in. An owner-and-deadline plan can be authored to+ // open as the table, the same way a chart can be authored to open in days.+ if (v.mode === 'table' || v.mode === 'gantt') d.view = v.mode; return d; } function savedPrefs(){@@ -334,13 +426,16 @@ function savedPrefs(){ try { return JSON.parse(localStorage.getItem(PREF_KEY)) || {}; } catch { return {}; } } const prefs = Object.assign(- { theme: 'dark', msStyle: 'line', granularity: 'month', showToday: true },+ { theme: 'dark', msStyle: 'line', granularity: 'month', showToday: true, view: 'gantt' }, viewDefaults(), savedPrefs() ); function savePrefs(){ try { localStorage.setItem(PREF_KEY, JSON.stringify(prefs)); } catch {} } function applyTheme(){ document.documentElement.setAttribute('data-theme', prefs.theme); } applyTheme();+// Set before the chart is built, so a plan that opens as a table never measures a+// hidden timeline. The toggle buttons do not exist yet on this first call.+applyView(); // ── Date range ── // The timeline is derived from the data every time, so shifting the plan reshapes@@ -467,6 +562,10 @@ $('hdr').innerHTML = ` </select> </div> <div class="actions">+ <div class="view-toggle" id="viewToggle" title="Two lenses over the same plan: the chart, or the owner and deadline table">+ <button class="vt-btn" data-view="gantt">Gantt</button>+ <button class="vt-btn" data-view="table">Table</button>+ </div> <button class="act-btn" id="btnMsStyle" title="Milestone style: diamond or dashed line"> <span id="msStyleIcon"></span><span id="msStyleLabel"></span></button> <button class="act-btn" id="btnTheme" title="Toggle light and dark"><span id="themeLabel"></span></button>@@ -474,6 +573,7 @@ $('hdr').innerHTML = ` <button class="act-btn" id="btnExport"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21V9m0 0l4 4m-4-4l-4 4M4 7V5a2 2 0 012-2h12a2 2 0 012 2v2"/></svg>Export</button> <button class="act-btn" id="btnHelp" style="font-family:'Familjen Grotesk',sans-serif;font-size:14px;font-weight:600;padding:4px 9px">?</button> </div>`;+applyView(); // again, now that the toggle buttons exist // The brand has three hues, so the ramp cycles them rather than inventing lighter // or darker fourths. On a dark ground a second teal has to be lighter to be told@@ -496,7 +596,9 @@ let depSvg; // ── Undo ── const undoStack = []; let undoTimer = null;-function snapshotMany(ids){ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r)undoStack.push({type:'edit',id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||''});});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);}+// The snapshot carries owner, status and description too, so a table edit is one+// Ctrl+Z like every other edit rather than a second class of change.+function snapshotMany(ids){ids.forEach(id=>{const r=items.find(d=>d.id===id);if(r)undoStack.push({type:'edit',id,s:r.start,e:r.end,deps:r.deps?[...r.deps]:[],note:r.note||'',owner:r.owner,status:r.status,desc:r.description});});if(undoStack.length>100)undoStack.splice(0,undoStack.length-100);} function snapshot(id){snapshotMany([id]);} // One undo entry covering many rows, for bulk operations like a plan shift. function snapshotGroup(ids){@@ -518,7 +620,12 @@ function undo(){ if(!undoStack.length)return; const sn=undoStack.pop(); if(sn.type==='order'){applyOrder(sn.order);}- else if(sn.type==='ms'){sn.r.date=sn.date;updateMilestone(sn.r);}+ else if(sn.type==='ms'){+ sn.r.date=sn.date;+ if(sn.name!==undefined)sn.r.name=sn.name;+ setOpt(sn.r,'owner',sn.owner);setOpt(sn.r,'status',sn.status);setOpt(sn.r,'description',sn.desc);+ refreshMsChrome(sn.r);updateMilestone(sn.r);+ } else if(sn.type==='group'){ // one Ctrl+Z undoes the whole shift, not 26 tasks one at a time sn.entries.forEach(en=>{const r=items.find(d=>d.id===en.id);if(!r)return;r.start=en.s;r.end=en.e;r.deps=en.deps;r.note=en.note;});@@ -526,7 +633,12 @@ function undo(){ (sn.phases||[]).forEach(x=>{x.p.start=x.start;x.p.end=x.end;}); rebuildTimeline();Object.keys(barEls).forEach(id=>updateBar(id)); }- else{const r=items.find(d=>d.id===sn.id);if(!r)return;r.start=sn.s;r.end=sn.e;r.deps=sn.deps;if(sn.note!==undefined)r.note=sn.note;updateBar(sn.id);}+ else{const r=items.find(d=>d.id===sn.id);if(!r)return;r.start=sn.s;r.end=sn.e;r.deps=sn.deps;if(sn.note!==undefined)r.note=sn.note;+ if('owner' in sn)setOpt(r,'owner',sn.owner);+ if('status' in sn)setOpt(r,'status',sn.status);+ if('desc' in sn)setOpt(r,'description',sn.desc);+ updateBar(sn.id);refreshLabelRow(r);}+ if(prefs.view==='table')renderTable(); clearTimeout(undoTimer);undoToast.classList.add('show');undoTimer=setTimeout(()=>undoToast.classList.remove('show'),1800);scheduleSave('undo'); } document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='z'){e.preventDefault();undo();}if(e.key==='Escape'){clearSelection();closeEditor();closeMenus();}});@@ -535,11 +647,17 @@ document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='z'){ let saveTimer=null; // Milestones carry no id, so an array of tasks alone loses their dates on reload and // they snap back to the data file while the tasks stay shifted. Persist both.+// An empty value means "no value", not the empty string, so clearing an owner in+// the table leaves the item as clean as it was in the data file.+function setOpt(r,k,v){ if(v===undefined||v===null||v==='')delete r[k]; else r[k]=v; }+// v3 adds owner, status and description. The fields are additive: a v2 sidecar+// written before the table view existed still loads, and a v2 reader ignores+// what it does not know. function getState(){ return {- v: 2,- tasks: items.filter(r=>r.id).map(r=>({id:r.id,s:r.start,e:r.end,deps:r.deps||[],note:r.note||''})),- milestones: items.filter(r=>r.type==='milestone').map((r,i)=>({i,name:r.name,date:r.date})),+ v: 3,+ tasks: items.filter(r=>r.id).map(r=>({id:r.id,s:r.start,e:r.end,deps:r.deps||[],note:r.note||'',owner:r.owner||'',status:r.status||'',desc:r.description||''})),+ milestones: items.filter(r=>r.type==='milestone').map((r,i)=>({i,name:r.name,date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||''})), phases: phases.map((p,i)=>({i,start:p.start,end:p.end})), order: items.map(r=>r.id||r.label||r.name), };@@ -550,12 +668,19 @@ function applyState(state){ (tasks||[]).forEach(sv=>{ const r=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;+ if(sv.owner!==undefined)setOpt(r,'owner',sv.owner);+ if(sv.status!==undefined)setOpt(r,'status',sv.status);+ if(sv.desc!==undefined)setOpt(r,'description',sv.desc); }); if(Array.isArray(state))return; const ms=items.filter(r=>r.type==='milestone'); (state.milestones||[]).forEach(sv=>{ const r=ms.find(m=>m.name===sv.name)||ms[sv.i];- if(r&&sv.date)r.date=sv.date;+ if(!r)return;+ if(sv.date)r.date=sv.date;+ if(sv.owner!==undefined)setOpt(r,'owner',sv.owner);+ if(sv.status!==undefined)setOpt(r,'status',sv.status);+ if(sv.desc!==undefined)setOpt(r,'description',sv.desc); }); (state.phases||[]).forEach(sv=>{ const p=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===items.length)applyOrder(state.order);@@ -578,8 +703,10 @@ function nameOf(id){const r=items.find(d=>d.id===id);return r?r.name:id;} function planStartOf(snap){let m=null;Object.values(snap.byId).forEach(v=>{if(v.start&&(!m||v.start<m))m=v.start;});return m;} function snapSemantic(){ const byId={};- items.forEach(r=>{if(r.id)byId[r.id]={start:r.start,end:r.end,deps:(r.deps||[]).slice(),note:r.note||''};});- const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[r.name]=r.date;});+ items.forEach(r=>{if(r.id)byId[r.id]={start:r.start,end:r.end,deps:(r.deps||[]).slice(),note:r.note||'',owner:r.owner||'',status:r.status||'',desc:r.description||''};});+ // Milestones carry no id, so they are keyed by name and hold the same shape as+ // a task minus the dates, which lets one diff cover both.+ const ms={};items.filter(r=>r.type==='milestone').forEach(r=>{ms[r.name]={date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||''};}); return {byId,ms,order:items.map(r=>r.id||r.label||r.name)}; } let lastSnap=snapSemantic();@@ -593,6 +720,9 @@ function diffAndLog(via){ shift:{start:daysBetween(a.start,b.start),end:daysBetween(a.end,b.end)}}); } if((a.note||'')!==(b.note||'')) evs.push({action:'note',id,name:nameOf(id),from:{note:a.note||''},to:{note:b.note||''}});+ if((a.owner||'')!==(b.owner||'')) evs.push({action:'owner',id,name:nameOf(id),from:{owner:a.owner||''},to:{owner:b.owner||''}});+ if((a.status||'')!==(b.status||'')) evs.push({action:'status',id,name:nameOf(id),from:{status:a.status||''},to:{status:b.status||''}});+ if((a.desc||'')!==(b.desc||'')) evs.push({action:'desc',id,name:nameOf(id),from:{desc:a.desc||''},to:{desc:b.desc||''}}); b.deps.filter(d=>!a.deps.includes(d)).forEach(d=>evs.push({action:'dep.add',id,name:nameOf(id),dep:d,depName:nameOf(d)})); a.deps.filter(d=>!b.deps.includes(d)).forEach(d=>evs.push({action:'dep.remove',id,name:nameOf(id),dep:d,depName:nameOf(d)})); });@@ -608,13 +738,18 @@ function diffAndLog(via){ from:{start:planStartOf(lastSnap)},to:{start:planStart()}},...kept); } }- // milestone dates: keyed by name, since milestones carry no id+ // milestones: keyed by name, since they carry no id const curMs={},oldMs=lastSnap.ms||{};- items.filter(r=>r.type==='milestone').forEach(r=>{curMs[r.name]=r.date;});+ items.filter(r=>r.type==='milestone').forEach(r=>{curMs[r.name]={date:r.date,owner:r.owner||'',status:r.status||'',desc:r.description||''};}); Object.keys(curMs).forEach(n=>{- if(oldMs[n]&&oldMs[n]!==curMs[n])- evs.push({action:'milestone',name:n,from:{date:oldMs[n]},to:{date:curMs[n]},- shift:{days:daysBetween(oldMs[n],curMs[n])}});+ const a=oldMs[n],b=curMs[n];+ if(!a)return;+ if(a.date!==b.date&&a.date&&b.date)+ evs.push({action:'milestone',name:n,from:{date:a.date},to:{date:b.date},+ shift:{days:daysBetween(a.date,b.date)}});+ if((a.owner||'')!==(b.owner||'')) evs.push({action:'owner',ms:true,name:n,from:{owner:a.owner||''},to:{owner:b.owner||''}});+ if((a.status||'')!==(b.status||'')) evs.push({action:'status',ms:true,name:n,from:{status:a.status||''},to:{status:b.status||''}});+ if((a.desc||'')!==(b.desc||'')) evs.push({action:'desc',ms:true,name:n,from:{desc:a.desc||''},to:{desc:b.desc||''}}); }); const before=lastSnap.order,after=cur.order; if(JSON.stringify(before)!==JSON.stringify(after)){@@ -623,23 +758,27 @@ function diffAndLog(via){ } lastSnap=cur; if(!evs.length)return;+ if(SNAPSHOT)return; // a snapshot has no server to record against fetch('log',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({actor:'user',events:evs.map(e=>({...e,via}))})}) .then(r=>r.json()).then(d=>{if(d&&d.rev)revEl.textContent='rev '+d.rev;}).catch(()=>{}); } function scheduleSave(via){diffAndLog(via||'edit');clearTimeout(saveTimer);saveTimer=setTimeout(saveToFile,800);}-function saveToFile(){fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(getState())}).then(r=>r.json()).then(()=>{flash(saveInd,'saved');}).catch(()=>{flash(saveInd,'save failed',2500);});}+// A snapshot is a file, not a server. The export used to try to rewrite this+// function with a regex that could never match its own source; the flag it+// already sets is enough.+function saveToFile(){if(SNAPSHOT){flash(saveInd,'snapshot mode');return;}fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(getState())}).then(r=>r.json()).then(()=>{flash(saveInd,'saved');}).catch(()=>{flash(saveInd,'save failed',2500);});} function flash(el,text,ms){el.textContent=text;el.classList.add('show');setTimeout(()=>el.classList.remove('show'),ms||1500);} // ── Legend ── const legendEl = $('legend'); phases.forEach((ph,i)=>{const el=document.createElement('div');el.className='legend-item';el.innerHTML=`<div class="legend-dot" style="background:${phaseColor(i)}"></div>${ph.name}`;el.addEventListener('click',()=>{phaseVis[i]=!phaseVis[i];el.classList.toggle('off',!phaseVis[i]);applyFilter();});legendEl.appendChild(el);}); const msL=document.createElement('div');msL.className='legend-item';msL.innerHTML='<div class="legend-diamond"></div>Milestone';msL.addEventListener('click',()=>{msVis=!msVis;msL.classList.toggle('off',!msVis);applyFilter();});legendEl.appendChild(msL);-const depL=document.createElement('div');depL.className='legend-item';depL.innerHTML='<svg width="14" height="10" style="flex-shrink:0"><path d="M0,5C4,5 10,5 14,5" stroke="var(--accent)" stroke-width="1.5" fill="none" opacity="0.6"/><polygon points="14,5 10,3 10,7" fill="var(--accent)" opacity="0.6"/></svg>Dependencies';+const depL=document.createElement('div');depL.className='legend-item gantt-only';depL.innerHTML='<svg width="14" height="10" style="flex-shrink:0"><path d="M0,5C4,5 10,5 14,5" stroke="var(--accent)" stroke-width="1.5" fill="none" opacity="0.6"/><polygon points="14,5 10,3 10,7" fill="var(--accent)" opacity="0.6"/></svg>Dependencies'; depL.addEventListener('click',()=>{depsVis=!depsVis;depL.classList.toggle('off',!depsVis);depSvg.classList.toggle('hidden',!depsVis);});legendEl.appendChild(depL); // Today toggles from the legend like Milestone and Dependencies, rather than adding // separate chrome: the legend is already where this app hides and shows chart elements.-const todayL=document.createElement('div');todayL.className='legend-item';+const todayL=document.createElement('div');todayL.className='legend-item gantt-only'; todayL.innerHTML='<div class="legend-line"></div>Today'; todayL.addEventListener('click',()=>{ prefs.showToday=!prefs.showToday;savePrefs();@@ -853,6 +992,9 @@ function sizeTailroom(){ } let rlTimer=null, lastPaneW=timelineEl.clientWidth, lastPaneH=timelineEl.clientHeight; function scheduleRelayout(){clearTimeout(rlTimer);rlTimer=setTimeout(()=>{+ // A hidden chart measures zero, which would collapse every column and the+ // milestone cap layout. Switching back to the chart relays out explicitly.+ if(prefs.view==='table')return; const w=timelineEl.clientWidth, h=timelineEl.clientHeight; if(w===lastPaneW && h===lastPaneH) return; // guard: ignore observer echo from our own writes lastPaneW=w; lastPaneH=h;@@ -869,10 +1011,12 @@ window.addEventListener('resize',scheduleRelayout); if(window.ResizeObserver)new ResizeObserver(scheduleRelayout).observe(timelineEl); // Web-font load changes tag widths and heights after the first measure, so re-run the // milestone cap collision layout once fonts settle, or tags can overlap on a cold load.-if(document.fonts&&document.fonts.ready)document.fonts.ready.then(()=>{if(prefs.msStyle==='line')layoutMsCaps();});+if(document.fonts&&document.fonts.ready)document.fonts.ready.then(()=>{if(prefs.msStyle==='line'&&prefs.view!=='table')layoutMsCaps();}); // ── Filter ──-function applyFilter(){items.forEach(r=>{let vis=true;if(r.phase!==undefined&&!phaseVis[r.phase])vis=false;if(r.type==='milestone'&&!msVis)vis=false;r._lDiv.classList.toggle('dim',!vis);r._bDiv.classList.toggle('dim',!vis);});drawDeps();}+// The legend's phase and milestone toggles are a statement about the plan, not+// about the chart, so the table honours them too and the two lenses agree.+function applyFilter(){items.forEach(r=>{let vis=true;if(r.phase!==undefined&&!phaseVis[r.phase])vis=false;if(r.type==='milestone'&&!msVis)vis=false;r._lDiv.classList.toggle('dim',!vis);r._bDiv.classList.toggle('dim',!vis);});drawDeps();if(prefs.view==='table')renderTable();} // ── Dependencies ── function drawDeps(){@@ -1035,7 +1179,7 @@ function openBatchEditor(){ editorEl.innerHTML=`<div class="drag-bar"></div> <div class="batch-title">${ids.length} tasks selected</div>- <div class="batch-hint">All changes are relative — each task shifts from its own dates</div>+ <div class="batch-hint">All changes are relative, each task shifts from its own dates</div> <div style="margin-bottom:10px"> <div class="de-row"> <div><label>earliest start</label><input type="date" id="bStart" value="${earliest}"></div>@@ -1079,9 +1223,18 @@ function updateMilestone(r){ drawDeps(); } function snapshotMs(r){- undoStack.push({type:'ms',r,date:r.date});+ undoStack.push({type:'ms',r,date:r.date,name:r.name,owner:r.owner,status:r.status,desc:r.description}); if(undoStack.length>100)undoStack.splice(0,undoStack.length-100); }+// Milestone chrome that is not the date: the label row, the tooltip body and the+// dashed rule's tag all restate the milestone, so they move together.+function refreshMsChrome(r){+ if(!r)return;+ if(r._lDiv)r._lDiv.textContent=r.name;+ if(r._msHit){r._msHit.dataset.title=r.name;r._msHit.dataset.body=r.description||'';}+ if(r._msLine){const cap=r._msLine.querySelector('.ms-cap');+ if(cap){cap.textContent=(r.name||'').replace(/^M(\d+):\s*/,'M$1 ');cap.dataset.title=r.name;cap.dataset.body=r.description||'';}}+} function updateBar(id){const r=items.find(d=>d.id===id),be=barEls[id];if(!r||!be)return;if(r._noteEl)r._noteEl.textContent=r.note||'';const x1=toX(r.start),x2=toX(r.end);be.bar.style.left=x1+'px';be.bar.style.width=Math.max(x2-x1,6)+'px';be.bar.dataset.dates=fmt(r.start)+' → '+fmt(r.end);drawDeps();} function closeEditor(){editorEl.classList.remove('show');editorEl.innerHTML='';}@@ -1329,6 +1482,12 @@ $('btnHelp').addEventListener('click',e=>{ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag horiz.</span>Move bar dates</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag vert.</span>Reorder rows</div> <div class="dd-sep"></div>+ <div class="dd-label">table view</div>+ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Gantt / Table</span>Same plan, two lenses</div>+ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click cell</span>Edit owner, dates, status, notes</div>+ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click header</span>Sort by that column</div>+ <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">owner</span>Filter to one person's work</div>+ <div class="dd-sep"></div> <div class="dd-label">keyboard</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+Z</span>Undo</div> <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Esc</span>Clear selection</div>@@ -1556,8 +1715,255 @@ document.addEventListener('mousemove',e=>{ }); document.addEventListener('mouseup',()=>{edDrag=null;}); +// ── Table view ──+//+// The second lens. Every task and milestone is one row, with the owner, the+// dates, the status and the notes editable in place. An edit here is the same+// kind of event as a drag on the chart: it mutates the item, it goes through+// scheduleSave, and it lands in the sidecar and the changelog. Nothing about+// the table is a separate store.++const tableBody=$('tableBody'), tableHead=$('tableHead'), ownerListEl=$('ownerList');+const ownerSel=$('tblOwner'), statusSel=$('tblStatus'), searchInp=$('tblSearch'), countEl=$('tblCount');+const STATUS_OPTS=['live','planned','not-started'];+const UNASSIGNED='__unassigned__'; // sentinel, so a real owner can never collide with it+let sortKey='deadline', sortDir=1, tblSearchTimer=null;+if(prefs.tableSort&&prefs.tableSort.key){sortKey=prefs.tableSort.key;sortDir=prefs.tableSort.dir===-1?-1:1;}++function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}+function isGroup(r){return r.type==='group';}+function isMs(r){return r.type==='milestone';}+// A task's deadline is its end; a milestone's is the date it stands on. One+// column, so sorting by deadline sorts the whole plan at once.+function deadlineOf(r){return isMs(r)?(r.date||''):(r.end||'');}+function phaseNameOf(r){const p=phases[r.phase];return p?p.name:'';}+function ownerOf(r){return (r.owner||'').trim();}+function msIndexOf(r){return items.filter(q=>isMs(q)).indexOf(r);}+function rowKey(r){return isMs(r)?('m:'+msIndexOf(r)):('t:'+r.id);}+function rowItem(key){+ if(!key)return null;+ if(key.charAt(0)==='t')return items.find(r=>r.id===key.slice(2));+ return msByIndex(+key.slice(2));+}+// Re-render the chart's own label row, so a status changed in the table is+// already true on the chart when you toggle back.+function refreshLabelRow(r){+ if(!r||!r._lDiv||!r.id)return;+ const tg=r.status?`<span class="tag ${statusTag[r.status]||''}">${esc(statusLabel[r.status]||r.status)}</span>`:'';+ r._lDiv.innerHTML=esc(r.name)+tg;+}++function distinctOwners(){+ const s=new Set();+ items.forEach(r=>{if(isGroup(r))return;const o=ownerOf(r);if(o)s.add(o);});+ return [...s].sort((a,b)=>a.localeCompare(b));+}+function syncOwnerControls(){+ const owners=distinctOwners();+ // On the first render there are no options yet, so a value set from prefs would+ // not have stuck. Fall back to the saved filter rather than the empty select.+ const cur=ownerSel.options.length?ownerSel.value:(prefs.tableOwner||'');+ ownerSel.innerHTML='<option value="">All owners</option>'+ +owners.map(o=>`<option value="${esc(o)}">${esc(o)}</option>`).join('')+ +`<option value="${UNASSIGNED}">Unassigned</option>`;+ ownerSel.value = (cur===''||cur===UNASSIGNED||owners.includes(cur)) ? cur : '';+ // Typing an owner offers the names already on the plan without forcing them.+ ownerListEl.innerHTML=owners.map(o=>`<option value="${esc(o)}"></option>`).join('');+}++function tableRows(){+ const q=(searchInp.value||'').trim().toLowerCase();+ const own=ownerSel.value, st=statusSel.value;+ const rows=[];+ items.forEach(r=>{+ if(isGroup(r))return;+ if(r.phase!==undefined&&!phaseVis[r.phase])return;+ if(isMs(r)&&!msVis)return;+ const o=ownerOf(r);+ if(own===UNASSIGNED){ if(o)return; } else if(own&&o!==own) return;+ if(st&&(r.status||'')!==st)return;+ if(q){+ const hay=`${r.name||''} ${o} ${phaseNameOf(r)} ${r.description||''} ${r.status||''} ${deadlineOf(r)}`.toLowerCase();+ if(hay.indexOf(q)<0)return;+ }+ rows.push(r);+ });+ const val=r=>{+ if(sortKey==='name')return (r.name||'').toLowerCase();+ if(sortKey==='phase')return (phaseNameOf(r)||'').toLowerCase();+ if(sortKey==='owner')return ownerOf(r).toLowerCase();+ if(sortKey==='start')return isMs(r)?'':(r.start||'');+ if(sortKey==='status')return r.status||'';+ if(sortKey==='notes')return (r.description||'').toLowerCase();+ return deadlineOf(r);+ };+ // A blank sorts last in both directions: an unassigned row should never push+ // the thing you are looking for off the top of the list.+ rows.sort((a,b)=>{+ const va=val(a), vb=val(b);+ if(va===vb) return (a._itemIdx||0)-(b._itemIdx||0);+ if(va==='') return 1;+ if(vb==='') return -1;+ return va<vb ? -sortDir : sortDir;+ });+ return rows;+}++function syncSortHeaders(){+ tableHead.querySelectorAll('th[data-sort]').forEach(th=>{+ const on=th.dataset.sort===sortKey;+ th.classList.toggle('sorted',on);+ const old=th.querySelector('.sort-caret'); if(old)old.remove();+ if(on){const c=document.createElement('span');c.className='sort-caret';c.textContent=sortDir===1?'▲':'▼';th.appendChild(c);}+ });+}++function renderTable(){+ if(!tableBody)return;+ syncOwnerControls();+ syncSortHeaders();+ const rows=tableRows();+ const total=items.filter(r=>!isGroup(r)).length;+ countEl.textContent = rows.length===total ? `${total} items` : `${rows.length} of ${total} items`;+ if(!rows.length){tableBody.innerHTML='<tr><td colspan="7" class="table-empty">No items match these filters.</td></tr>';return;}+ tableBody.innerHTML=rows.map(r=>{+ const ms=isMs(r), editable=ms||!!r.id;+ const o=ownerOf(r), st=r.status||'', dl=deadlineOf(r), sd=ms?'':(r.start||'');+ const dot=r.phase!==undefined?`<span class="phase-dot" style="background:${phaseColor(r.phase)}"></span>`:'';+ const dates=ms?(dl?fmt(dl):''):((sd&&dl)?fmt(sd)+' → '+fmt(dl):'');+ const ed=editable?' editable':'';+ return `<tr data-key="${esc(rowKey(r))}"${ms?' class="is-ms"':''}>`+ +`<td class="c-name"><span class="trunc" data-title="${esc(r.name)}" data-dates="${esc(dates)}" data-body="${esc(r.description||'')}">`+ +`${ms?'<span class="ms-mark">◆</span>':''}${esc(r.name)}</span></td>`+ +`<td class="c-phase">${dot}${esc(phaseNameOf(r))}</td>`+ +`<td class="c-owner${ed}${o?'':' empty'}" data-field="owner">${o?esc(o):'unassigned'}</td>`+ +(ms?'<td class="c-date empty"></td>'+ :`<td class="c-date${ed}${sd?'':' empty'}" data-field="start">${sd?esc(sd):'not set'}</td>`)+ +`<td class="c-date${ed}${dl?'':' empty'}" data-field="deadline">${dl?esc(dl):'not set'}</td>`+ +`<td class="c-status${ed}" data-field="status">${st?`<span class="tag ${statusTag[st]||'tag-not-started'}">${esc(statusLabel[st]||st)}</span>`:'<span class="tag tag-not-started">none</span>'}</td>`+ +`<td class="c-notes${ed}${r.description?'':' empty'}" data-field="notes"><span class="trunc">${r.description?esc(r.description):'none'}</span></td>`+ +'</tr>';+ }).join('');+}++// ── Inline cell editing ──+let tblEditing=null;+function startCellEdit(td){+ if(tblEditing)return;+ const tr=td.closest('tr'); if(!tr)return;+ const r=rowItem(tr.dataset.key), field=td.dataset.field;+ if(!r||!field)return;+ let el;+ if(field==='status'){+ el=document.createElement('select');+ el.innerHTML='<option value="">none</option>'+STATUS_OPTS.map(s=>`<option value="${s}">${statusLabel[s]}</option>`).join('');+ el.value=r.status||'';+ } else if(field==='start'||field==='deadline'){+ el=document.createElement('input');el.type='date';+ el.value=(field==='start'?r.start:deadlineOf(r))||'';+ } else if(field==='owner'){+ el=document.createElement('input');el.type='text';el.setAttribute('list','ownerList');+ el.placeholder='one name';el.value=r.owner||'';+ } else {+ el=document.createElement('input');el.type='text';el.value=r.description||'';+ }+ el.className='cell-input';+ td.innerHTML='';td.appendChild(el);+ el.focus(); if(el.tagName==='INPUT'&&el.type==='text')el.select();+ tblEditing={td,el,r,field};+ let done=false;+ const finish=save=>{+ if(done)return;done=true;tblEditing=null;+ if(save)commitCell(r,field,el.value);+ renderTable();+ };+ // Ctrl+Z inside a field is the browser's own text undo, and Esc means "cancel+ // this cell", not "clear the chart selection", so the app's keys stop here.+ el.addEventListener('keydown',e=>{e.stopPropagation();+ if(e.key==='Enter'){e.preventDefault();finish(true);}+ else if(e.key==='Escape'){e.preventDefault();finish(false);}});+ el.addEventListener('blur',()=>finish(true));+ if(el.tagName==='SELECT')el.addEventListener('change',()=>finish(true));+}++function commitCell(r,field,v){+ const ms=isMs(r);+ const snap=()=>{ if(ms)snapshotMs(r); else snapshot(r.id); };+ if(field==='owner'){+ v=v.trim().replace(/\s+/g,' ');+ if((r.owner||'')===v)return;+ snap();setOpt(r,'owner',v);+ } else if(field==='status'){+ if((r.status||'')===v)return;+ snap();setOpt(r,'status',STATUS_OPTS.includes(v)?v:'');+ } else if(field==='notes'){+ v=v.trim();+ if((r.description||'')===v)return;+ snap();setOpt(r,'description',v);+ } else if(field==='deadline'){+ if(!v)return;+ if(ms){ if(r.date===v)return; snap(); r.date=v; }+ else { if(r.end===v)return; snap(); r.end=v;+ // A deadline pulled in front of the start would draw a backwards bar.+ if(r.start&&r.start>v)r.start=v; }+ } else if(field==='start'){+ if(!v||ms||r.start===v)return;+ snap();r.start=v;+ if(r.end&&r.end<v)r.end=v;+ } else return;++ if(ms){refreshMsChrome(r);updateMilestone(r);}+ else{+ updateBar(r.id);refreshLabelRow(r);+ const be=barEls[r.id];if(be)be.bar.dataset.body=r.description||'';+ }+ scheduleSave('table-edit');+}++// ── Table wiring ──+tableHead.addEventListener('click',e=>{+ const th=e.target.closest('th[data-sort]');if(!th)return;+ const k=th.dataset.sort;+ if(sortKey===k)sortDir=-sortDir;else{sortKey=k;sortDir=1;}+ prefs.tableSort={key:sortKey,dir:sortDir};savePrefs();+ renderTable();+});+tableBody.addEventListener('click',e=>{const td=e.target.closest('td.editable');if(td)startCellEdit(td);});+ownerSel.addEventListener('change',()=>{prefs.tableOwner=ownerSel.value;savePrefs();renderTable();});+statusSel.addEventListener('change',renderTable);+searchInp.addEventListener('keydown',e=>e.stopPropagation());+searchInp.addEventListener('input',()=>{clearTimeout(tblSearchTimer);tblSearchTimer=setTimeout(renderTable,120);});+$('tblClear').addEventListener('click',()=>{+ ownerSel.value='';statusSel.value='';searchInp.value='';+ prefs.tableOwner='';savePrefs();renderTable();+});++// ── View switching ──+function applyView(){+ const v = (prefs.view==='table') ? 'table' : 'gantt';+ document.documentElement.setAttribute('data-view',v);+ const vt=$('viewToggle');+ if(vt)vt.querySelectorAll('.vt-btn').forEach(b=>b.classList.toggle('on',b.dataset.view===v));+}+function setView(v){+ if(v!=='table'&&v!=='gantt')return;+ if(prefs.view===v)return;+ prefs.view=v;savePrefs();applyView();+ if(v==='table'){renderTable();return;}+ // The chart measured nothing while it was hidden, so lay it out from scratch+ // once it is back on screen.+ requestAnimationFrame(()=>{+ lastPaneW=timelineEl.clientWidth;lastPaneH=timelineEl.clientHeight;lastRelayoutW=lastPaneW;+ relayout();sizeTailroom();drawDeps();+ });+}+$('viewToggle').addEventListener('click',e=>{const b=e.target.closest('.vt-btn');if(b)setView(b.dataset.view);});+ // ── Init ── 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){}+// The sidecar can carry a status or a description that the chart's own label rows+// and tooltips were built from the data file with, so restate them.+items.forEach(r=>{ if(r.id)refreshLabelRow(r); else if(r.type==='milestone')refreshMsChrome(r); }); // Re-baseline AFTER restoring the sidecar, so replaying saved edits on load is // not mistaken for a fresh round of user changes. lastSnap=snapSemantic();@@ -1566,6 +1972,9 @@ try{const r=await fetch('changelog');if(r.ok){const cl=await r.json();revEl.text rebuildTimeline(); sizeTailroom(); drawDeps();+// "What's mine" is a standing question, so the owner filter survives a reload+// (syncOwnerControls picks prefs.tableOwner up on this first render).+renderTable(); })(); </script>
package.json+10−3@@ -2,8 +2,8 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.7.1",- "description": "Interactive Gantt chart viewer for project timelines. 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. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",+ "version": "0.8.0",+ "description": "Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": { "adom-gantt": "server.js"@@ -24,7 +24,10 @@ "view the project schedule", "plot our milestones on a timeline", "import this MS Project file",- "export the schedule as SVG"+ "export the schedule as SVG",+ "who owns what on this project",+ "show the plan as a table",+ "what are my tasks and deadlines" ], "discovery_pitch": "Reach for adom-gantt when a project's phases, tasks, milestones and dependencies need to be seen and edited on a timeline rather than read as a list.", "sample_prompts": [@@ -39,6 +42,10 @@ { "label": "Import", "prompt": "import this Jira CSV and show it as a gantt chart"+ },+ {+ "label": "What is mine",+ "prompt": "open the DC demo plan as a table and filter it to my tasks" } ], "scripts": {
page.json+11−4@@ -2,8 +2,8 @@ "slug": "adom-gantt", "title": "Adom Gantt", "type": "app",- "version": "0.7.1",- "description": "Interactive Gantt chart viewer for project timelines. 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. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",+ "version": "0.8.0",+ "description": "Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.", "main": "server.js", "bin": { "adom-gantt": "server.js"@@ -24,7 +24,10 @@ "view the project schedule", "plot our milestones on a timeline", "import this MS Project file",- "export the schedule as SVG"+ "export the schedule as SVG",+ "who owns what on this project",+ "show the plan as a table",+ "what are my tasks and deadlines" ], "discovery_pitch": "Reach for adom-gantt when a project's phases, tasks, milestones and dependencies need to be seen and edited on a timeline rather than read as a list.", "sample_prompts": [@@ -39,6 +42,10 @@ { "label": "Import", "prompt": "import this Jira CSV and show it as a gantt chart"+ },+ {+ "label": "What is mine",+ "prompt": "open the DC demo plan as a table and filter it to my tasks" } ], "scripts": {@@ -56,4 +63,4 @@ "name": "Drew Owens", "email": "[email protected]" }-}+}
server.js+37−10@@ -122,6 +122,22 @@ function describeEvent(e) { if (!t) return `${who} cleared the label on ${name} (was "${f}")${via}`; return `${who} relabelled ${name} "${f}" -> "${t}"${via}`; }+ if (e.action === 'owner') {+ const f = e.from && e.from.owner, t = e.to && e.to.owner;+ if (!f) return `${who} assigned ${name} to ${t}${via}`;+ if (!t) return `${who} left ${name} unassigned (was ${f})${via}`;+ return `${who} reassigned ${name} from ${f} to ${t}${via}`;+ }+ if (e.action === 'status') {+ const f = (e.from && e.from.status) || 'none', t = (e.to && e.to.status) || 'none';+ return `${who} set ${name} status ${f} -> ${t}${via}`;+ }+ if (e.action === 'desc') {+ const f = e.from && e.from.desc, t = e.to && e.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 (e.action === 'dep.add') return `${who} made ${name} depend on "${e.depName || e.dep}"${via}`; if (e.action === 'dep.remove') return `${who} removed ${name}'s dependency on "${e.depName || e.dep}"${via}`; if (e.action === 'reorder') return `${who} reordered rows: ${(e.moved || []).join(', ') || 'row order changed'}${via}`;@@ -150,6 +166,14 @@ function changelogMarkdown(log, since, rev) { return lines.join('\n') + '\n'; } +// 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;+}+ function mergedData() { const data = loadData(); const state = loadState();@@ -163,12 +187,20 @@ function mergedData() { 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); }); if (Array.isArray(state)) return data; const ms = data.items.filter(r => r.type === 'milestone'); (state.milestones || []).forEach(sv => { const r = ms.find(m => m.name === sv.name) || ms[sv.i];- if (r && sv.date) r.date = sv.date;+ if (!r) return;+ if (sv.date) r.date = sv.date;+ setOpt(r, 'owner', sv.owner);+ setOpt(r, 'status', sv.status);+ setOpt(r, 'description', sv.desc); }); (state.phases || []).forEach(sv => { const p = data.phases[sv.i];@@ -236,7 +268,7 @@ function parseCSV(text) { } function itemsToCSV(items) {- const headers = ['id', 'name', 'start', 'end', 'phase', 'status', 'description', 'deps', 'type', 'label', 'date', 'striped'];+ const headers = ['id', 'name', 'start', 'end', 'phase', 'owner', 'status', 'description', 'deps', 'type', 'label', 'date', 'striped']; const escape = v => { if (v == null) return ''; const s = String(v);@@ -327,6 +359,7 @@ function importCSV(body, queryString) { 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.status) { item.status = row.status; } if (row.description) { item.description = row.description; } if (deps.length) { item.deps = deps; }@@ -711,9 +744,6 @@ function generateHTMLSnapshot() { // We set state to already-applied (merged data already has state applied) const inlineStateScript = `const r={ok:true,json:async()=>({empty:true})};`; - // Replace the save function to be a no-op in snapshot mode- const noopSave = `function saveToFile() { saveInd.textContent='snapshot mode';saveInd.classList.add('show');setTimeout(()=>saveInd.classList.remove('show'),1500); }`;- let html = ganttHtml; // Replace the data fetch line@@ -728,11 +758,8 @@ function generateHTMLSnapshot() { '/* state already merged into inline data */' ); - // Replace saveToFile function- html = html.replace(- /function saveToFile\(\) \{[^}]+\}/,- noopSave- );+ // 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');