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
name: adom-gantt description: > Interactive Gantt chart viewer for Adom project timelines. Serves a 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. 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. Use when the user wants to see, build or edit a project plan on a timeline, when they ask who owns what or what is due when, or when they need to read what a human changed in a plan before changing it.
adom-gantt
Interactive Gantt chart viewer for Adom project timelines.
Quick start
node ~/project/adom-gantt/server.js --data my-project.json --port 8901
Then open via Hydrogen webview or pup browser window at the proxy URL.
Data file schema
Create a JSON file with this structure:
{
"project": {
"title": "My Project",
"subtitle": "Short description",
"appName": "my-project"
},
"phases": [
{
"name": "Phase 0 — Setup",
"start": "2026-06",
"end": "2026-08"
}
],
"items": [
{
"type": "group",
"label": "Phase 0 — Setup (Jun–Aug 2026)",
"phase": 0
},
{
"type": "task",
"id": "unique-id",
"name": "Task Name",
"start": "2026-06-01",
"end": "2026-07-15",
"phase": 0,
"status": "planned",
"description": "What this task involves.",
"deps": ["other-task-id"],
"striped": false
},
{
"type": "milestone",
"name": "M1: First milestone",
"date": "2026-08-31",
"phase": 0,
"description": "What this milestone means."
}
]
}
Field reference
project (required)
| Field | Type | Required | Description |
|---|---|---|---|
| title | string | yes | Project name shown in header |
| subtitle | string | no | Short tagline (default: "Gantt chart") |
| appName | string | no | Shown in brand zone (default: "adom-gantt") |
| view | object | no | The author's intended default view, see below |
project.view (optional) sets how the chart opens. It applies to the HTML export, to a freshly served chart, and to any browser with no saved preference. In the live app a preference the user has set for themselves still wins; an exported snapshot ignores saved preferences entirely so it always opens as authored.
"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 |
| theme | dark light |
Opening theme |
| labelWidth | number (120-760) | Name column width in px |
Set this when a chart is meant to be shared, so the snapshot on a wiki page matches the hero image that invited the click.
phases[] (required, at least 1)
| Field | Type | Required | Description |
|---|
| name | string | yes | Legend label (e.g., "Phase 1 — Build") |
| color | hex string | no | Overrides the brand ramp for this phase. Omit it to stay theme-aware, see Phase colors below | | start | YYYY-MM | yes | Phase start month | | end | YYYY-MM | yes | Phase end month |
items[] (required). An ordered array, rendered top-to-bottom
Each item has a type field: "group", "task", or "milestone".
type: "group": section header row
| Field | Type | Required | Description |
|---|---|---|---|
| label | string | yes | Group header text |
| phase | number | yes | Index into phases[] (controls filter visibility) |
type: "task": bar on the timeline
| Field | Type | Required | Description |
|---|---|---|---|
| id | string | yes | Unique identifier (used for deps and state) |
| name | string | yes | Task label |
| start | YYYY-MM-DD | yes | Start date. Required: the chart is a function of dates |
| end | YYYY-MM-DD | yes | End date. Required |
| phase | number | yes | Index into phases[] |
| owner | string | no | The one person accountable. A single name, never a team. Drives the table's owner filter |
| provenance | object or string | no | Where this row came from. {"label":"POR","url":"https://..."} or just "Drew added this". Only http and https urls render as links; anything else shows as text |
| status | string | no | One of: "not-started", "in-progress", "blocked", "complete". The old "live" and "planned" are still read and shown as in progress / not started |
| 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) |
type: "milestone": diamond marker, or a dashed vertical rule if the user has switched milestone style
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | yes | Milestone label |
| date | YYYY-MM-DD | yes | Milestone date, and the Deadline column in the table |
| phase | number | yes | Index into phases[] |
| 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
Leave color off and the phase follows the Adom brand ramp. That is the
recommended default: the ramp is theme-aware, so the same plan stays on-brand in
dark mode and stays legible when printed. Setting an explicit color opts that
phase out of theming, and it will not adapt for paper.
The ramp cycles teal, purple, blue:
| Phase | Dark (default) | Light / print |
|---|---|---|
| 1, 4, 7 ... | #00b8b1 teal |
#00a49d |
| 2, 5, 8 ... | #8c6bf7 purple |
#8c6bf7 |
| 3, 6, 9 ... | #64abff blue |
#3b95ff |
The ramp cycles the three brand hues rather than inventing lighter or darker fourths. On a dark ground a second teal has to be lighter to be told apart, and a pale teal reads as off-brand. Sections are separated by the group header instead, so a repeat three rows down is not confusing. Adjacent phases are always different.
There is no background band behind a phase. The bar colour and the group header row carry the section on their own, which keeps the chart body clean and means colour on the chart says one thing only: which phase a bar belongs to.
Green, red and yellow are deliberately absent. In the Adom design system those mean success, destructive and warning, so a phase colored red reads as a problem rather than a category.
Features
- New button (or Shift+N): create a task or milestone. After a successful create, the next New panel prefills the owner and both dates from the one you just made, so a run of tasks for the same person in the same week is names and nothing else. Name and notes always start empty; status, provenance and phase keep their defaults. It is in-memory for the page session only: a refresh clears it, and it is never written to localStorage, the preferences or the sidecar, so it is never carried to anyone else. Cancel does not update it, only a create that produced a row. Created rows live in the sidecar as
added: [...], keyed in a#new-namespace that cannot collide with the data file's#row<n>, and are merged into every export - Text size A-minus/A+ in the header: seven steps from 85% to 150%, applies to the chart and the table, persists in
localStorageasprefs.fs. Row and bar geometry scale with the type - Fit button beside the cell-size picker: picks the finest grain whose whole span fits the pane, so the plan is fully visible with no horizontal scrolling. Chart-only
- Gantt / Table toggle pinned first in the header, left of every conditional control so its position never shifts. 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). Every timeline drag snaps to the grid the axis is currently drawing: day boundaries at the day cell size, week starts at weeks, month, quarter and year firsts at the coarser ones. Resizing the start leaves the end where it is and the mirror for the end; dragging the bar body snaps the START and preserves the DURATION, so the end lands at start plus the same number of days. The preview snaps live, line to line, and is exactly what gets committed. The shortest bar a resize can produce is the first grid line past the fixed edge, so it is never zero or negative. Milestones snap the same way. Dates set explicitly (table cells, the item modal, the batch editor, the project-start shift) are taken exactly as given and are never rounded
- Ctrl+Z to undo (100-level stack)
- Up / Down in the table move the selected row, following the visible order under any sort or filter. They never scroll the pane on their own: the browser's default is suppressed and the pane moves only enough to keep the selected row in view. The first press selects the first visible row. In the chart the arrows are left alone
- Enter on the selected row opens its NAME editor, ready to type with the text selected. Inside a cell editor Enter commits and steps down one row, the spreadsheet convention, so a column of values is Enter, type, Enter, type; it does not open the next editor, and a second Enter does
- Shift+N opens the New panel, gated on the app being idle: no editor, panel, menu or drag in progress, and nothing focused that a person could be typing into
- Alt+Up / Alt+Down moves one row one slot. The table moves its focused row (the row whose cell editor was touched last, marked with an accent bar on its left edge); the chart moves its selected task. In plan order the movement is free, and stepping over a group header re-homes the row into that lane, phase, bar colour and dashed rule together, exactly as a drag does. Under a column sort the movement is bounded to the contiguous run of rows the sort considers TIED with this one, so an owner-sorted Adonis task moves within the Adonis block and nowhere else: the visible sort stays truthful and what changes is the tie-break, which is plan position, which persists. A row alone in its sort key does not move and says so. Undo is per press; the changelog coalesces a burst into one line
- Tab / Shift+Tab inside a table cell editor commits the field and opens the next editable cell, with the same ready-to-type conventions as a click (a select opens its list, short text is selected, notes get the caret at the end). It skips read-only cells and the delete button, follows the visible order under a sort, and wraps across rows and around both ends of the table
- Auto-save to
<datafile>-state.jsonsidecar (800ms debounce, flushed immediately if the page is closed or hidden, so the changelog and the sidecar cannot disagree) - Auto-load saved state on page refresh
- Dynamic date range computed from the data every render
- Project start in the header. Changing it moves the WHOLE plan: every task, milestone and phase shifts by the same number of days, so durations and gaps are preserved. Revert puts it back to the data file's own start. This is a real edit, recorded and undoable, not a view setting
- Cell size picker: Days, Weeks, Months, Quarters, Years. Each grain has its own minimum cell width sized to the widest label it draws, so a fine grain scrolls rather than squashing while a coarse one stretches to fill the pane. Day and week positions are exact; month, quarter and year approximate within a month
- Bar notes put a short label on the bar body. Double-click a bar to type one, or use the note field in the editor
- Milestones are editable too: drag one sideways to move its date, or click it for a panel with its name, date and description. Line style is the default: the tag on the dashed rule is the handle, and the milestone's grid row is hidden since the rule already carries it. Diamond style keeps the row. Long titles wrap, and any tag that would still collide with its neighbour drops to a second row, so tags never overlap
- Dynamic today marker shows the current date, positioned with the same grain-aware mapping as the bars, so it is correct at every cell size
- Resizable name column: drag the divider, or double-click it to fit the longest name. Width persists in
localStorage - Table columns are a fixed grid. Default widths are per column type and identical for every plan (Name 23%, Notes 22%, Provenance 14%, Phase 10%, Owner 8%, Status 8%, Start 7.5%, Deadline 7.5%, delete 30px). Nothing in the body can change them: clicking a cell, opening or cancelling an editor, hovering, sorting, filtering, adding or deleting a row all leave every column exactly where it was. Widths change only when the user drags a header grip or double-clicks one to fit the column to its content, and those persist in
prefs.tableCols, after which the table is pixel-driven - Light mode for printing (dark is the default; printing always uses the light theme)
- Scroll past the last row so the bottom of the plan can sit centred in the pane
View preferences (theme, milestone style, cell size, today marker, name column width) persist in
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 |
| Provenance | provenance as a chip, linked when the url is http(s) |
same |
Full editor. Hovering a task or milestone row in the chart's label column reveals an Edit button that opens every field. Group rows have a collapse chevron instead. The table intentionally has no modal: its cells are all editable in place.
Table column widths are draggable and persist in prefs.tableCols; the table switches to fixed layout once any width is user-set.
Collapse. Each phase header carries a chevron and a row count. Collapsed lanes are view state in localStorage (prefs.collapsed), not plan state, so they are per-person and never written to the data file or the sidecar. Collapsing hides rows only: the date axis and Fit are unaffected.
Label editing. A single click on a chart label edits the name inline; a double-click opens the full item editor. Dragging a task across a group header changes its phase.
Reordering. Click and hold a row (about 350ms) to lift it, drag, drop. Only available in plan order, since a sorted view is a lens and not the plan. Dropping into another lane sets phase and position as one undo. On the chart, dragging a group header moves that whole lane.
Dates display as 17-Aug-2026. Stored values (data file, sidecar, changelog payloads, CSV) stay ISO yyyy-mm-dd.
Sorting. Click any header. Three states: ascending, descending, then back to plan order. 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 Name, Phase, Owner, Start, Deadline, Status, Notes or Provenance in place, and the button at the end of a row deletes it (one click, undoable, with a toast). Moving a row to another phase re-homes it under that lane's group header on the chart. Deleted rows are recorded in the sidecar as deleted: [key, ...] and applied before the row order, so a deliberate removal is never mistaken for a lost key.
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 and milestone.rename. Milestone
versions carry ms: true and a key instead of an id, since milestones have no id.
project.view.mode pins which lens a plan opens in, the same way grain pins the
cell size. Use it when the plan is an assignment list more than a timeline.
Read the changelog before you edit a plan
Every edit a human makes in the browser is appended to <datafile>-changelog.jsonl.
Read it before you touch a plan the user has been working in, so you build on their
decisions instead of resetting them.
curl -s localhost:8901/changelog?format=md # readable, whole history
curl -s "localhost:8901/changelog?since=12&format=md" # only what changed after rev 12
curl -s localhost:8901/changelog # JSON: {rev, count, touched[], events[]}
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, table-edit, milestone-editor, start-picker,
start-revert, undo).
Actions are dates, plan.shift, milestone, milestone.rename, note, owner,
status, desc, dep.add, dep.remove and reorder.
Every event must carry a string action, and events must be an array; POST /log
returns 400 otherwise, because the log is append-only and anything accepted is
permanent. A malformed event never breaks a reader: ?format=md renders whatever it
can and says ? where a field is missing.
An import records a single import event. It replaces the data file and clears the
sidecar, so treat everything logged above that event as history of a different plan.
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.
The workflow. Note the rev you saw. Next time the user asks you to change the plan,
ask for ?since=<that rev> first. Anything in touched[] is a deliberate human decision:
work relative to their current dates, and say so rather than silently overwriting. If you
must overwrite one, tell the user which and why.
When you change the plan yourself, record it so the next reader can tell your work from theirs:
curl -s -X POST localhost:8901/log -H 'Content-Type: application/json' \
-d '{"actor":"ai","events":[{"action":"dates","id":"dvt","name":"DVT build",
"from":{"start":"2026-07-01","end":"2026-08-15"},
"to":{"start":"2026-07-15","end":"2026-08-29"},
"shift":{"start":14,"end":14},"via":"agent-replan"}]}'
The log is append-only and never rewritten. POST /changelog/reset deletes it outright,
so only run it when the user asks for a clean slate.
Dates are required
A task needs a start and an end; a milestone needs a date, each looking like
2026-08-27. All four import endpoints reject the whole file with a 400 naming the
offending rows rather than defaulting a date, because an invented date is
indistinguishable from a real one once it is in the file.
A hand-edited data file never passes through an importer, so the viewer is tolerant too: a row it cannot date is left out of the chart and named in a warning, rather than taking the other 72 rows down with it. Fix the data file and reload.
Group labels
Do not put a date range in a group label. "Phase 1 - Design (Jan-Apr 2026)" renders as
Phase 1 - Design: the axis already states the dates, and the text goes stale the moment
the plan is shifted. The strip is narrow, only a trailing parenthetical that begins with a
month abbreviation and carries a four-digit year, so (EVT) and (Year 1-2) are kept.
State file
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, key, name, date, owner, status, desc}, where key is the milestone's stable
row key (#row<n>, its index in the data file). order is a list of those same
stable keys, so renaming a milestone no longer changes its key. Milestone entries
resolve by key first, then by a unique name, then by index, and each milestone is
claimed at most once, so two milestones sharing a name still edit independently. A
task entry with no id is ignored rather than matched against the first id-less row. 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
node ~/project/adom-gantt/server.js --data ~/project/adom-gantt/examples/smt-line.json
This loads the Adom SMT Supervisory Line project, a 32-month, 5-phase plan with 30+ tasks, 8 milestones, and dependency arrows.
---
name: adom-gantt
description: >
Interactive Gantt chart viewer for Adom project timelines. Serves a
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. 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.
Use when the user wants to see, build or edit a project plan on a
timeline, when they ask who owns what or what is due when, or when they
need to read what a human changed in a plan before changing it.
---
# adom-gantt
Interactive Gantt chart viewer for Adom project timelines.
## Quick start
```bash
node ~/project/adom-gantt/server.js --data my-project.json --port 8901
```
Then open via Hydrogen webview or pup browser window at the proxy URL.
## Data file schema
Create a JSON file with this structure:
```json
{
"project": {
"title": "My Project",
"subtitle": "Short description",
"appName": "my-project"
},
"phases": [
{
"name": "Phase 0 — Setup",
"start": "2026-06",
"end": "2026-08"
}
],
"items": [
{
"type": "group",
"label": "Phase 0 — Setup (Jun–Aug 2026)",
"phase": 0
},
{
"type": "task",
"id": "unique-id",
"name": "Task Name",
"start": "2026-06-01",
"end": "2026-07-15",
"phase": 0,
"status": "planned",
"description": "What this task involves.",
"deps": ["other-task-id"],
"striped": false
},
{
"type": "milestone",
"name": "M1: First milestone",
"date": "2026-08-31",
"phase": 0,
"description": "What this milestone means."
}
]
}
```
### Field reference
**project** (required)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| title | string | yes | Project name shown in header |
| subtitle | string | no | Short tagline (default: "Gantt chart") |
| appName | string | no | Shown in brand zone (default: "adom-gantt") |
| view | object | no | The author's intended default view, see below |
**project.view** (optional) sets how the chart opens. It applies to the HTML export, to
a freshly served chart, and to any browser with no saved preference. In the live app a
preference the user has set for themselves still wins; an exported snapshot ignores saved
preferences entirely so it always opens as authored.
```json
"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 |
| theme | `dark` `light` | Opening theme |
| labelWidth | number (120-760) | Name column width in px |
Set this when a chart is meant to be shared, so the snapshot on a wiki page matches the
hero image that invited the click.
**phases[]** (required, at least 1)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
<!-- prose-lint-disable (literal legend label from the shipped example data) -->
| name | string | yes | Legend label (e.g., "Phase 1 — Build") |
<!-- prose-lint-enable -->
| color | hex string | no | Overrides the brand ramp for this phase. Omit it to stay theme-aware, see Phase colors below |
| start | YYYY-MM | yes | Phase start month |
| end | YYYY-MM | yes | Phase end month |
**items[]** (required). An ordered array, rendered top-to-bottom
Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`.
**type: "group"**: section header row
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| label | string | yes | Group header text |
| phase | number | yes | Index into phases[] (controls filter visibility) |
**type: "task"**: bar on the timeline
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | string | yes | Unique identifier (used for deps and state) |
| name | string | yes | Task label |
| start | YYYY-MM-DD | yes | Start date. Required: the chart is a function of dates |
| end | YYYY-MM-DD | yes | End date. Required |
| phase | number | yes | Index into phases[] |
| owner | string | no | The one person accountable. A single name, never a team. Drives the table's owner filter |
| provenance | object or string | no | Where this row came from. `{"label":"POR","url":"https://..."}` or just `"Drew added this"`. Only http and https urls render as links; anything else shows as text |
| status | string | no | One of: "not-started", "in-progress", "blocked", "complete". The old "live" and "planned" are still read and shown as in progress / not started |
| 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) |
**type: "milestone"**: diamond marker, or a dashed vertical rule if the user has switched milestone style
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | yes | Milestone label |
| date | YYYY-MM-DD | yes | Milestone date, and the Deadline column in the table |
| phase | number | yes | Index into phases[] |
| 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
**Leave `color` off and the phase follows the Adom brand ramp.** That is the
recommended default: the ramp is theme-aware, so the same plan stays on-brand in
dark mode and stays legible when printed. Setting an explicit `color` opts that
phase out of theming, and it will not adapt for paper.
The ramp cycles teal, purple, blue:
| Phase | Dark (default) | Light / print |
|-------|----------------|---------------|
| 1, 4, 7 ... | `#00b8b1` teal | `#00a49d` |
| 2, 5, 8 ... | `#8c6bf7` purple | `#8c6bf7` |
| 3, 6, 9 ... | `#64abff` blue | `#3b95ff` |
The ramp cycles the three brand hues rather than inventing lighter or darker fourths.
On a dark ground a second teal has to be lighter to be told apart, and a pale teal
reads as off-brand. Sections are separated by the group header instead, so a repeat three rows down is
not confusing. Adjacent phases are always different.
There is no background band behind a phase. The bar colour and the group header row
carry the section on their own, which keeps the chart body clean and means colour on
the chart says one thing only: which phase a bar belongs to.
Green, red and yellow are deliberately absent. In the Adom design system those
mean success, destructive and warning, so a phase colored red reads as a problem
rather than a category.
## Features
- **New** button (or Shift+N): create a task or milestone. After a successful create, the next New panel prefills the owner and both dates from the one you just made, so a run of tasks for the same person in the same week is names and nothing else. Name and notes always start empty; status, provenance and phase keep their defaults. It is in-memory for the page session only: a refresh clears it, and it is never written to localStorage, the preferences or the sidecar, so it is never carried to anyone else. Cancel does not update it, only a create that produced a row. Created rows live in the sidecar as `added: [...]`, keyed in a `#new-` namespace that cannot collide with the data file's `#row<n>`, and are merged into every export
- **Text size** A-minus/A+ in the header: seven steps from 85% to 150%, applies to the chart and the table, persists in `localStorage` as `prefs.fs`. Row and bar geometry scale with the type
- **Fit** button beside the cell-size picker: picks the finest grain whose whole span fits the pane, so the plan is fully visible with no horizontal scrolling. Chart-only
- **Gantt / Table toggle** pinned first in the header, left of every conditional control so its position never shifts. 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). Every timeline drag snaps to the grid the axis is currently drawing: day boundaries at the day cell size, week starts at weeks, month, quarter and year firsts at the coarser ones. Resizing the start leaves the end where it is and the mirror for the end; dragging the bar body snaps the START and preserves the DURATION, so the end lands at start plus the same number of days. The preview snaps live, line to line, and is exactly what gets committed. The shortest bar a resize can produce is the first grid line past the fixed edge, so it is never zero or negative. Milestones snap the same way. Dates set explicitly (table cells, the item modal, the batch editor, the project-start shift) are taken exactly as given and are never rounded
- **Ctrl+Z** to undo (100-level stack)
- **Up / Down** in the table move the selected row, following the visible order under any sort or filter. They never scroll the pane on their own: the browser's default is suppressed and the pane moves only enough to keep the selected row in view. The first press selects the first visible row. In the chart the arrows are left alone
- **Enter** on the selected row opens its NAME editor, ready to type with the text selected. Inside a cell editor Enter commits and steps down one row, the spreadsheet convention, so a column of values is Enter, type, Enter, type; it does not open the next editor, and a second Enter does
- **Shift+N** opens the New panel, gated on the app being idle: no editor, panel, menu or drag in progress, and nothing focused that a person could be typing into
- **Alt+Up / Alt+Down** moves one row one slot. The table moves its focused row (the row whose cell editor was touched last, marked with an accent bar on its left edge); the chart moves its selected task. In plan order the movement is free, and stepping over a group header re-homes the row into that lane, phase, bar colour and dashed rule together, exactly as a drag does. Under a column sort the movement is bounded to the contiguous run of rows the sort considers TIED with this one, so an owner-sorted Adonis task moves within the Adonis block and nowhere else: the visible sort stays truthful and what changes is the tie-break, which is plan position, which persists. A row alone in its sort key does not move and says so. Undo is per press; the changelog coalesces a burst into one line
- **Tab / Shift+Tab** inside a table cell editor commits the field and opens the next editable cell, with the same ready-to-type conventions as a click (a select opens its list, short text is selected, notes get the caret at the end). It skips read-only cells and the delete button, follows the visible order under a sort, and wraps across rows and around both ends of the table
- **Auto-save** to `<datafile>-state.json` sidecar (800ms debounce, flushed immediately if the page is closed or hidden, so the changelog and the sidecar cannot disagree)
- **Auto-load** saved state on page refresh
- **Dynamic date range** computed from the data every render
- **Project start** in the header. Changing it moves the WHOLE plan: every task, milestone and phase shifts by the same number of days, so durations and gaps are preserved. Revert puts it back to the data file's own start. This is a real edit, recorded and undoable, not a view setting
- **Cell size** picker: Days, Weeks, Months, Quarters, Years. Each grain has its own minimum cell width sized to the widest label it draws, so a fine grain scrolls rather than squashing while a coarse one stretches to fill the pane. Day and week positions are exact; month, quarter and year approximate within a month
- **Bar notes** put a short label on the bar body. Double-click a bar to type one, or use the note field in the editor
- **Milestones are editable too**: drag one sideways to move its date, or click it for a panel with its name, date and description. Line style is the default: the tag on the dashed rule is the handle, and the milestone's grid row is hidden since the rule already carries it. Diamond style keeps the row. Long titles wrap, and any tag that would still collide with its neighbour drops to a second row, so tags never overlap
- **Dynamic today marker** shows the current date, positioned with the same grain-aware mapping as the bars, so it is correct at every cell size
- **Resizable name column**: drag the divider, or double-click it to fit the longest name. Width persists in `localStorage`
- **Table columns are a fixed grid.** Default widths are per column type and identical for every plan (Name 23%, Notes 22%, Provenance 14%, Phase 10%, Owner 8%, Status 8%, Start 7.5%, Deadline 7.5%, delete 30px). Nothing in the body can change them: clicking a cell, opening or cancelling an editor, hovering, sorting, filtering, adding or deleting a row all leave every column exactly where it was. Widths change only when the user drags a header grip or double-clicks one to fit the column to its content, and those persist in `prefs.tableCols`, after which the table is pixel-driven
- **Light mode** for printing (dark is the default; printing always uses the light theme)
- **Scroll past the last row** so the bottom of the plan can sit centred in the pane
View preferences (theme, milestone style, cell size, today marker, name column width) persist in
`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 |
| Provenance | `provenance` as a chip, linked when the url is http(s) | same |
**Full editor.** Hovering a task or milestone row in the chart's label column reveals an Edit button that opens every field. Group rows have a collapse chevron instead. The table intentionally has no modal: its cells are all editable in place.
**Table column widths** are draggable and persist in `prefs.tableCols`; the table switches to fixed layout once any width is user-set.
**Collapse.** Each phase header carries a chevron and a row count. Collapsed lanes are view state in `localStorage` (`prefs.collapsed`), not plan state, so they are per-person and never written to the data file or the sidecar. Collapsing hides rows only: the date axis and Fit are unaffected.
**Label editing.** A single click on a chart label edits the name inline; a double-click opens the full item editor. Dragging a task across a group header changes its phase.
**Reordering.** Click and hold a row (about 350ms) to lift it, drag, drop. Only available in plan order, since a sorted view is a lens and not the plan. Dropping into another lane sets phase and position as one undo. On the chart, dragging a group header moves that whole lane.
**Dates** display as `17-Aug-2026`. Stored values (data file, sidecar, changelog payloads, CSV) stay ISO `yyyy-mm-dd`.
**Sorting.** Click any header. Three states: ascending, descending, then back to plan order. 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 Name, Phase, Owner, Start, Deadline, Status, Notes or Provenance in place, and the button at the end of a row deletes it (one click, undoable, with a toast). Moving a row to another phase re-homes it under that lane's group header on the chart. Deleted rows are recorded in the sidecar as `deleted: [key, ...]` and applied before the row order, so a deliberate removal is never mistaken for a lost key.
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` and `milestone.rename`. Milestone
versions carry `ms: true` and a `key` instead of an `id`, since milestones have no id.
`project.view.mode` pins which lens a plan opens in, the same way `grain` pins the
cell size. Use it when the plan is an assignment list more than a timeline.
## Read the changelog before you edit a plan
Every edit a human makes in the browser is appended to `<datafile>-changelog.jsonl`.
Read it before you touch a plan the user has been working in, so you build on their
decisions instead of resetting them.
```bash
curl -s localhost:8901/changelog?format=md # readable, whole history
curl -s "localhost:8901/changelog?since=12&format=md" # only what changed after rev 12
curl -s localhost:8901/changelog # JSON: {rev, count, touched[], events[]}
```
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`, `table-edit`, `milestone-editor`, `start-picker`,
`start-revert`, `undo`).
Actions are `dates`, `plan.shift`, `milestone`, `milestone.rename`, `note`, `owner`,
`status`, `desc`, `dep.add`, `dep.remove` and `reorder`.
Every event must carry a string `action`, and `events` must be an array; `POST /log`
returns 400 otherwise, because the log is append-only and anything accepted is
permanent. A malformed event never breaks a reader: `?format=md` renders whatever it
can and says `?` where a field is missing.
An import records a single `import` event. It replaces the data file and clears the
sidecar, so treat everything logged above that event as history of a different plan.
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.
**The workflow.** Note the `rev` you saw. Next time the user asks you to change the plan,
ask for `?since=<that rev>` first. Anything in `touched[]` is a deliberate human decision:
work relative to their current dates, and say so rather than silently overwriting. If you
must overwrite one, tell the user which and why.
When you change the plan yourself, record it so the next reader can tell your work from
theirs:
```bash
curl -s -X POST localhost:8901/log -H 'Content-Type: application/json' \
-d '{"actor":"ai","events":[{"action":"dates","id":"dvt","name":"DVT build",
"from":{"start":"2026-07-01","end":"2026-08-15"},
"to":{"start":"2026-07-15","end":"2026-08-29"},
"shift":{"start":14,"end":14},"via":"agent-replan"}]}'
```
The log is append-only and never rewritten. `POST /changelog/reset` deletes it outright,
so only run it when the user asks for a clean slate.
## Dates are required
A task needs a `start` and an `end`; a milestone needs a `date`, each looking like
`2026-08-27`. All four import endpoints reject the whole file with a 400 naming the
offending rows rather than defaulting a date, because an invented date is
indistinguishable from a real one once it is in the file.
A hand-edited data file never passes through an importer, so the viewer is tolerant
too: a row it cannot date is left out of the chart and named in a warning, rather
than taking the other 72 rows down with it. Fix the data file and reload.
## Group labels
Do not put a date range in a group label. `"Phase 1 - Design (Jan-Apr 2026)"` renders as
`Phase 1 - Design`: the axis already states the dates, and the text goes stale the moment
the plan is shifted. The strip is narrow, only a trailing parenthetical that begins with a
month abbreviation and carries a four-digit year, so `(EVT)` and `(Year 1-2)` are kept.
## State file
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, key, name, date, owner, status, desc}`, where `key` is the milestone's stable
row key (`#row<n>`, its index in the data file). `order` is a list of those same
stable keys, so renaming a milestone no longer changes its key. Milestone entries
resolve by key first, then by a unique name, then by index, and each milestone is
claimed at most once, so two milestones sharing a name still edit independently. A
task entry with no `id` is ignored rather than matched against the first id-less row. 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
```bash
node ~/project/adom-gantt/server.js --data ~/project/adom-gantt/examples/smt-line.json
```
This loads the Adom SMT Supervisory Line project, a 32-month, 5-phase
plan with 30+ tasks, 8 milestones, and dependency arrows.