---
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.
