Skip to main content

A bar is an Entry

Shipped. The Segment type no longer exists — the word does. A Segment is a Bar on a row that draws more than one Bar, and nothing stores one. A bar is an ordinary child Entry, described in CONTEXT.md.

The claim, in one line​

Every bar on the timeline is one Entry. A normal bar, one of several bars sharing a row, and a summary rail are the same authored shape. Two questions decide which one a reader sees, and neither one is stored on the Entry:

  1. Does it have children? That decides derivation and the default look today.
  2. Does a rule on the row source match its parent? That decides whether it gets a row of its own, or draws on its parent's row. This is the one new question. The rule reads one parent Entry at a time, so the same key answers "every parent", "the parents this Field marks" and "this one parent, right now".

A Segment becomes a regular Entry with parentId set. Nothing on the child marks it.

Three bars, one shape​

AUTHORED ENTRIES ROWS AND BARS 1 — A normal bar a leaf Entry, on its own row { id: 't1', name: 'Design', start: '2026-09-01', end: '2026-09-07' } no parent, no children Design t1 2 — A segment bar a child Entry, drawn on its parent's row { id: 'req-1', showDaysOnRow: true } { id: 'd1', parentId: 'req-1', hours: 8, start, end } { id: 'd2', parentId: 'req-1', hours: 4, start, end } rowSource: { … childrenAsSegments: { showDaysOnRow: true } } Framing crew 12 h req-1 start/end — written by the Rollup d1 · 8 h d2 · 4 h one Row, three Entries — req-1 draws no bar of its own 3 — A summary bar the same three Entries, with the rule off { id: 'req-1', showDaysOnRow: true } { id: 'd1', parentId: 'req-1', hours: 8, start, end } { id: 'd2', parentId: 'req-1', hours: 4, start, end } rowSource: { source: 'entries', tree: true } Framing crew d1 d1 · 8 h d2 d2 · 4 h
Bands 2 and 3 hold the same three Entries. Only the row source differs. The rule matches req-1 in band 2, so its children draw on its row and get no rows of their own; with the rule off they are ordinary sub-rows and req-1 wears core's summary() rail. The Rollup runs identically in both: it writes req-1's hours cell (12) and its start/end envelope from the two children, because a parent derives its rolling-up Fields whatever its row source does. Band 1 is the same machinery with nothing to roll up.

The API, in call sites​

Every job below already ships, except one row.

const entries = [
{ id: 'req-1', name: 'Framing crew', showDaysOnRow: true }, // the row
{ id: 'd1', parentId: 'req-1', start, end, hours: 8 }, // a bar: a plain Entry
{ id: 'd2', parentId: 'req-1', start, end, hours: 4, filled: true },
{ id: 'hold', name: 'Site hold', start, end }, // a plain row, as today
];

const dataset = new Dataset<Props>({
// Core ships `name`, `start`, `end` and `duration`. Every Field below is this consumer's own.
fields: [
{ key: 'showDaysOnRow', type: 'boolean' }, // the marker the row rule reads
{ key: 'hours', type: 'number', rollUp: 'sum' },
{ key: 'filled', type: 'boolean' },
],
entries,
});

new Gantt({
container,
dataset,
rowSource: { source: 'entries', childrenAsSegments: { showDaysOnRow: true } },
});

This dataset is two levels deep and draws two rows: req-1, carrying d1 and d2 as bars, and hold. It names no tree, because no row nests under another one here. tree is a separate question, and the next section answers it.

JobCall siteNew?
Author a bar with data{ id: 'd1', parentId: 'req-1', start, end, hours: 8 }no
Draw every parent's children on its rowrowSource: { …, childrenAsSegments: true }the one new key
Draw one parent's children on its rowrowSource: { …, childrenAsSegments: { showDaysOnRow: true } }, and mark that parentthe same key
Open one row into sub-rows, livedataset.entries.update('req-1', { showDaysOnRow: false })no
Read a bar's valueentry.read('hours')no
Name the row a bar sits onentry.parent()no
List a row's barsentry.children()no
Total bar values onto the row{ key: 'hours', type: 'number', rollUp: 'sum' }no
Patch one bardataset.entries.update('d2', { hours: 6 })no
Add one bardataset.entries.add({ parentId: 'req-1', start, end, hours: 8 })no
Remove a bardataset.entries.remove('d1')no — several bars are several calls in one transaction
Move a bar to another rowdataset.entries.update('d1', { parentId: 'req-2' })no
Give a bar its own lookvariants: [{ name: 'fullDay', when: { hours: 8 }, paint, css }]no
Gate a gesture for every barcapabilities: { resize: (entry) => entry.read('filled') !== true }no
Gate a gesture for one look's barsvariants: [{ name: 'fullDay', when: { hours: 8 }, can: { resize: false } }]no
Turn off drag to another row, in both panescapabilities: { reorder: false }no
Read the change{ store: 'entries', id: 'd2', field: 'hours', from: 4, to: 6 }no
Propose a cascadean EditExtender returns EntryEditsno
Show the same bars as sub-rowschange the rule, or the value it matchesno

Two doors gate a gesture, and they do not compete. A bar's capability resolves down one chain: the consumer's own capabilities, then the resolved Variant's capabilities, then the library rule. Both doors take the same Capabilities shape, and a predicate at either level answers undefined for "no opinion", which falls to the next level. Write can when the rule belongs to the look — a milestone never resizes, wherever it is drawn. Write capabilities when the rule belongs to this Gantt — a read-only board resizes nothing, whatever a row looks like.

Set it for the whole Gantt, or for one Entry​

One key answers both. childrenAsSegments takes true for every parent, or an EntryRule (src/layout/entry-rule.ts) — the same when pattern a variant takes. The rule runs once per parent Entry in the layout pass, so the scope of the setting is whatever the rule says:

const forms: readonly (EntryRule | true)[] = [
true, // every parent
{ team: 'framing' }, // any value the data already holds
{ showDaysOnRow: true }, // the parents the consumer marks
(entry) => entry.children().length > 3, // whatever a predicate can ask
];

The common case is the shorthand and the long form is the expert one, as every other config key on this library reads. true fits a dataset two levels deep, where every parent carries bars. Name the level with a match or a predicate when the dataset is deeper.

A field match names a Field and a value, and nothing else. { showDaysOnRow: true } matches every parent whose showDaysOnRow Field equals true. { team: 'framing' } matches every parent whose team Field equals 'framing'. The key is a Field key the Dataset declares — a core one, or the consumer's own — and the comparison is that Field's own equals. A key no Field declares matches nothing, and the miss reports once per rule and key on the error event (code: 'unknown-row-source-field', severity: 'warning', with a console.warn fallback when nothing subscribes) — the frame keeps drawing, and the typo is loud rather than silent. unknown-variant-field is a different code, for variants' own when. A match is equality, never "has a value": ask that with a predicate.

It does not name a variant, and it does not pick one. variants writes the same shape in its when, so an author learns one match syntax — but the two answer different questions. when asks how does this row look; childrenAsSegments asks does this parent give its children rows. A segmented parent's children each still resolve their own variant afterwards, and that is the per-bar name, look and capability this design enables.

tree is a separate question​

tree says whether a non-segmented parent's children nest under it. The new rule says whether a segmented parent's children become rows at all. Neither reads the other, and the fold runs in both the flat and the tree branch of resolveEntriesSource:

treechildrenAsSegmentsWhat a reader sees
false (default)noneevery Entry is a row, flat — today's grid
falsematches req-1req-1 is a row with two bars; d1/d2 have no rows. Still a flat grid
truenoned1/d2 nest under req-1 as sub-rows, with a chevron — today's treegrid
truematches req-1req-1 holds the bars; a parent the rule does not match still nests

So the two-level roster names no tree, and gets no tree chrome for rows that do not nest. A dataset with a grouping level above the segmented parents names it, and the next section is that case.

A summary row above, segmented rows below​

A segmented row is an ordinary row, so an ordinary parent may sit above it. The rule matches the parents that carry bars; their own parent matches no rule, keeps its row, wears core's summary() and rolls up as it always has:

const deeper = [
{ id: 'site-a', name: 'Site A' }, // a summary row
{ id: 'req-1', parentId: 'site-a', name: 'Framing crew', showDaysOnRow: true },
{ id: 'req-2', parentId: 'site-a', name: 'Roofing crew', showDaysOnRow: true },
{ id: 'd1', parentId: 'req-1', start, end, hours: 8 }, // a bar on req-1's row
{ id: 'd2', parentId: 'req-1', start, end, hours: 4 },
];

const rowSource: EntriesRowSource = {
source: 'entries',
tree: true,
childrenAsSegments: { showDaysOnRow: true },
};

Three rows: site-a with a summary rail over everything below it, then req-1 and req-2, each carrying its own days as bars. site-a collapses and expands through the chevron, as a parent of rows does today. The Rollup runs over the whole tree in one bottom-up pass, so site-a's hours cell totals every day under both crews. No stage of the pass asks whether a row carries bars of its own children.

A segmented row is a summary in the grid already. The segmented parent's cells roll up from its children — req-1 reads 12 h with d1 and d2 on its row. The one thing the design suppresses is the parent's own bar, so summary()'s rail does not paint over the children it stands for. Core ships nothing to put one back: no rail key, no rail concept, no helper. A consumer who wants a band behind the bars writes one variant with a producer of their own, and that producer ignores the third parameter rather than reading it:

const bars: BarProducer = (entry, variant) => [wholeEntryBar(entry, variant)];

This producer always draws, segmented row or not — it is what a rail actually wants. wholeSpanUnlessSegments, the parameter it ignores, is core's own answer of when to suppress; a consumer producer that wants its band to survive claiming skips that question and always paints, so it always sits behind the children's bars rather than disappearing the moment childrenAsSegments matches the row.

:::note Terminology in this page The library retired Item in favour of Bar, so this page writes Bar, bars and wholeSpanUnlessSegments. MenuItem and CellItem keep the word "item" for menu rows and cells. :::

:::note Why the key says "segments" Read the call site aloud: "row source: entries, children as segments, where show-days-on-row is true." The name says what the children become, and it discriminates — an parent no rule matches's children draw a bar on a row of their own, and never a segment of another row's bar. The key carries no Row, because it already sits on rowSource and a name does not repeat its own context.

The word is free because the type is gone. Segment stopped being a stored type and comes back in the glossary with one meaning and nothing behind it: a child Entry drawn as one piece of its parent's row. Rejected: childrenAsRowSegments, childrenOnParentRow, childrenAsBars (a parent no rule matches has children that draw bars too), mergeChildRows (the mechanism, not the job) and splitRow ("Split" is under Avoid in CONTEXT.md). :::

How it flows through the layout pass​

ONE LAYOUT PASS — THE SAME PASS FOR ALL THREE BARS Entry[] dataset.entries.all resolveRows() layout/rows/resolve-rows.ts → PlannedRow[] produceBarsForRow() layout/bars/produce-bars.ts → Bar[] placeFrame() layout/frame.ts — the TimeScale → FrameBar[] backend.sync() render/dom — keyed reconcile .fg-bar data-variant, data-state THE ONE NEW DECISION A segmented parent's children fold into its own row.entryIds and get no row of their own. entries-source.ts:40 writes that list today. ALREADY PER-ENTRY The loop resolves a variant per Entry, not per row — so each bar keeps its own name, props, variant and capabilities. That is the whole change. Unchanged: geometry, paint, hit tests and the reconciler never learn how many Entries a Row owns. A custom row already owns several.
The three bars take one path. They part at two points only, and one of the two already ships: produceBarsForRow loops row.entryIds and calls registry.resolveFor(entry) per Entry, so a shared row carries several variants today. The new work is the fold at resolveEntriesSource, plus suppressing a segmented parent's own Bar so summary()'s rail does not paint over its children.

What each stage answers, per case​

StageA normal barA segment barA summary bar
resolveRowsone row, entryIds: ['t1']one row, entryIds: ['req-1','d1','d2']; children get noneone row each; children nest at depth + 1
Row is expandablenono — the rule opens it, not a chevronyes
resolveFor(entry)bar(), the last resortper child: whatever rule matches itsummary() matches on entry.hasChildren
variant.bars(entry)one Bar over [start, end)one Bar per child Entry; the parent itself produces noneone rail Bar (wholeSpanUnlessSegments)
Rollup writesnothingthe parent's hours, start, endthe same three, identically
placeFrameone FrameBarone FrameBar per Barone rail FrameBar
Selection unitthe Entrythe Entrythe Entry

How a bar gets updated​

EDIT ONE SEGMENT BAR'S VALUE — THE PATH EVERY MUTATION TAKES entries.update('d2', { hours: 6 }) api/dataset.ts runTransaction() body runs on a staged store extension hook identityExtender by default called once, EntryEdits in and out the Rollup data/rollup.ts — bottom-up one ChangeSet beforeChange → change → undo { store: 'entries', id: 'd2', field: 'hours', from: 4, to: 6 } ← the bar's own write { store: 'entries', id: 'req-1', field: 'hours', from: 12, to: 14 } ← the Rollup's write onto the row One store. One address shape. One undo step covering both rows. A date write is the same path: the bar's own start/end change, and the Rollup rewrites the row's envelope from its children. One transaction per gesture, at commit. A drag preview allocates nothing and writes nothing until the pointer lifts.
The envelope is the Rollup, not a second mechanism. A direct write to req-1.start is refused with DerivedFieldNotWritableError, the refusal every parent already gives. Nothing here branches on whether the Entry draws on its own row or its parent's: the write door never learns the row source.

How a bar moves to another row​

dataset.entries.update('d1', { parentId: 'req-2' }) BEFORE Framing crew d1 d2 Roofing crew d3 AFTER — ONE WRITE Framing crew d2 Roofing crew d3 d1 The id, the values, the Selection and the undo row all survive. Two Rollups re-run — the old parent's and the new one's — in the same transaction.
This is the main gesture of a shift roster, and the clearest gain over a Segment type: a Segment belongs to its Entry, so moving one between rows is a remove plus an add, and the id does not survive. Here it is an ordinary parentId write, and the Hierarchy source answers the new tree.

What each layer sees​

LayerWhat changes
model/Segment, StoredSegment, SegmentId, SegmentInput, SegmentEdit and the four Segment errors are deleted. Nothing replaces them
data/updateSegment, addSegment, removeSegments and the store: 'segments' apply path go. The Rollup already gives a parent its children's values, and that is now also the envelope
layout/One new key on EntriesRowSource, one fold in resolveEntriesSource, and a segmented parent draws no Bar of its own. followSegments is deleted; ignoreSegments becomes wholeSpanUnlessSegments
render/FrameRow.segmentIds, FrameBar.segmentIds and Bar.segmentId go. A bar keys on its BarId and names its EntryId, as it did before Segments
view/ + interaction/selectedSegmentIds, segmentIdsForBar, segmentIdsForRow and the segmentIds half of DomTarget and CommandTarget go. The Selection holds Entry ids
scheduling/Unaffected by this page. A link to a split piece of work names the parent or one child, and the scheduling plugin rules that

What it costs​

CostWhere it stands
PerformanceMeasured, and the objection falls. 10,000 bars build a frame 20% cheaper as child Entries than as Segments. One write and one row resolution grew, and each halves once the Rollup stops re-deriving an index the store already memoizes. A browser measurement of the hover path is still owed
A parent's own barCore draws no bar for a segmented parent. A consumer variant may still paint a rail.
The Selection unitThe Entry. 16 non-test files that named segmentIds now use EntryIds instead.
A shared row's nine call sitesentryIds[0] is read nine times in seven files. Seven mean "the row's subject", and a segmented row holds N+1 ids. Two mean "the first selected Entry", and a segmented row can select several. Each one is read once and fixed.
Mixed childrenThe rule matches the parent, so all of that parent's children draw as Segments. A parent with days on its row and sub-tasks below cannot be expressed. No consumer has asked for it
A segment child with children of its ownA segment rule is a collapse, one level deeper. The segmented parent draws its direct children as bars; the subtree below loses its rows, and a child that derives draws one rolled-up bar — what a collapsed parent's bar does today
MigrationThis library has never shipped to a user, so the segments key is deleted outright. 59 non-test sites and 37 test files that read it have been updated.
Copying a row with its barstoInput() copies one Entry. A subtree copy needs its own door — with or without this design