diff --git a/docs/usage.md b/docs/usage.md
index 2ebaf99..cd2a53f 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -19,6 +19,7 @@ Python OOUI supports several graph types:
- **line**: Line charts for trend visualization
- **bar**: Bar charts for comparative data
- **pie**: Pie charts for proportional data
+- **gantt**: One interval per record, with start and end fields
- **indicator**: Single-value indicators
- **indicatorField**: Field-based indicators
@@ -65,6 +66,25 @@ result = graph.process(data, fields)
print(result) # Processed graph data ready for visualization
```
+For a Y-axis range slider, set `y_range="slider"` on a line or bar graph.
+The processed result includes `yAxisOpts: {'mode': 'slider'}`; line graphs also
+retain their existing `yAxisProps` response.
+
+Gantt graphs use one X field for the task name and two Y fields with `start`
+and `end` roles. An optional `label` on either Y field groups intervals:
+
+```xml
+
+
+
+
+
+```
+
+`process(records, fields)` keeps each record as a separate interval, even when
+task names repeat. The result contains `id`, `x`, `start`, `end`, `type`, and
+`record` for each interval, plus `num_items`, `isGroup`, and `isStack`.
+
### Indicator Graphs
Indicators display single KPI values and support several display modes:
diff --git a/ooui/graph/__init__.py b/ooui/graph/__init__.py
index 82fe251..9093b55 100644
--- a/ooui/graph/__init__.py
+++ b/ooui/graph/__init__.py
@@ -2,6 +2,7 @@
from lxml import etree
from ooui.graph.indicator import GraphIndicator, GraphIndicatorField
from ooui.graph.chart import GraphChart
+from ooui.graph.gantt import GraphGantt
GRAPH_TYPES = {
@@ -10,6 +11,7 @@
'line': GraphChart,
'pie': GraphChart,
'bar': GraphChart,
+ 'gantt': GraphGantt,
}
diff --git a/ooui/graph/chart.py b/ooui/graph/chart.py
index 96eb084..ba8ae0f 100644
--- a/ooui/graph/chart.py
+++ b/ooui/graph/chart.py
@@ -172,4 +172,7 @@ def process(self, values, fields, options=None):
y_axis_props['valueOpts'] = get_min_max(final_data)
result['yAxisProps'] = y_axis_props
+ if self.type in ('line', 'bar') and self.y_range == 'slider':
+ result['yAxisOpts'] = {'mode': 'slider'}
+
return result
diff --git a/ooui/graph/gantt.py b/ooui/graph/gantt.py
new file mode 100644
index 0000000..41fb301
--- /dev/null
+++ b/ooui/graph/gantt.py
@@ -0,0 +1,76 @@
+from __future__ import absolute_import, unicode_literals
+
+from ooui.graph.base import Graph
+
+
+class GraphGantt(Graph):
+ def __init__(self, graph_type, element):
+ super(GraphGantt, self).__init__(element)
+ self._type = graph_type
+
+ graph_fields = element.findall('field')
+ if any(not field.get('name') or not field.get('axis')
+ for field in graph_fields):
+ raise ValueError('A Gantt field requires name and axis attributes')
+
+ x_fields = [field for field in graph_fields if field.get('axis') == 'x']
+ y_fields = [field for field in graph_fields if field.get('axis') == 'y']
+ start_fields = [field for field in y_fields
+ if field.get('role') == 'start']
+ end_fields = [field for field in y_fields
+ if field.get('role') == 'end']
+ if (len(x_fields) != 1 or len(y_fields) != 2
+ or len(start_fields) != 1 or len(end_fields) != 1):
+ raise ValueError('A Gantt chart requires one x, start and end field')
+
+ self.x = x_fields[0].get('name')
+ self.start = start_fields[0].get('name')
+ self.end = end_fields[0].get('name')
+ self.group = start_fields[0].get('label') or end_fields[0].get('label')
+ self._fields = [self.x]
+ for field in y_fields:
+ for name in (field.get('name'), field.get('label')):
+ if name and name not in self._fields:
+ self._fields.append(name)
+
+ @property
+ def fields(self):
+ return self._fields
+
+ def _label(self, record, fields, name, uninformed_string):
+ field = fields[name]
+ value = record.get(name)
+ if field.get('type') == 'many2one':
+ value = value[1] if value else None
+ elif field.get('type') == 'selection':
+ value = next((
+ label for key, label in field.get('selection', [])
+ if key == value and isinstance(key, bool) == isinstance(value, bool)
+ ), None)
+ return value or uninformed_string
+
+ def process(self, values, fields, options=None):
+ options = options or {}
+ uninformed_string = options.get('uninformedString', 'Not informed')
+ data = []
+ for record in values:
+ if self.group:
+ label = self._label(record, fields, self.group,
+ uninformed_string)
+ else:
+ label = fields[self.start].get('string') or self.start
+ data.append({
+ 'id': record.get('id'),
+ 'x': self._label(record, fields, self.x, uninformed_string),
+ 'start': record.get(self.start),
+ 'end': record.get(self.end),
+ 'type': label,
+ 'record': record,
+ })
+ return {
+ 'type': self.type,
+ 'data': data,
+ 'num_items': len(values),
+ 'isGroup': bool(self.group),
+ 'isStack': False,
+ }
diff --git a/spec/graph/gantt_spec.py b/spec/graph/gantt_spec.py
new file mode 100644
index 0000000..284ff48
--- /dev/null
+++ b/spec/graph/gantt_spec.py
@@ -0,0 +1,79 @@
+from mamba import *
+from expects import *
+from ooui.graph import parse_graph
+
+
+with description('Gantt graph'):
+ with it('preserves every Gantt interval and labels selection zero separately from false'):
+ xml = (''
+ ''
+ ''
+ ''
+ '')
+ graph = parse_graph(xml)
+ expect(graph.fields).to(equal(['name', 'start', 'group', 'end']))
+ fields = {
+ 'name': {'type': 'char'},
+ 'start': {'type': 'datetime', 'string': 'Start'},
+ 'end': {'type': 'datetime'},
+ 'group': {'type': 'selection', 'selection': [(0, 'Zero'), (1, 'One')]},
+ }
+ values = [
+ {'id': 1, 'name': 'Task', 'start': '2026-09-22 08:00:00',
+ 'end': '2026-09-22 10:00:00', 'group': 0},
+ {'id': 2, 'name': 'Task', 'start': '2026-09-22 11:00:00',
+ 'end': '2026-09-22 12:00:00', 'group': False},
+ ]
+ result = graph.process(values, fields, {'uninformedString': 'Sense informar'})
+ expect(result['type']).to(equal('gantt'))
+ expect(result['num_items']).to(equal(2))
+ expect(result['isGroup']).to(be_true)
+ expect(result['isStack']).to(be_false)
+ expect([item['type'] for item in result['data']]).to(equal(['Zero', 'Sense informar']))
+ expect([item['id'] for item in result['data']]).to(equal([1, 2]))
+ expect([item['record'] for item in result['data']]).to(equal(values))
+
+ with it('uses relational group labels and leaves repeated task names separate'):
+ xml = (''
+ ''
+ ''
+ ''
+ '')
+ graph = parse_graph(xml)
+ expect(graph.fields).to(equal(['name', 'start', 'end', 'project']))
+ fields = {
+ 'name': {'type': 'char'},
+ 'start': {'type': 'date', 'string': 'Start'},
+ 'end': {'type': 'date'},
+ 'project': {'type': 'many2one'},
+ }
+ values = [
+ {'id': 1, 'name': 'Task', 'start': '2026-09-22',
+ 'end': '2026-09-23', 'project': [5, 'North']},
+ {'id': 2, 'name': 'Task', 'start': '2026-09-24',
+ 'end': '2026-09-25', 'project': [6, 'South']},
+ ]
+ result = graph.process(values, fields)
+ expect([item['type'] for item in result['data']]).to(equal(['North', 'South']))
+ expect([item['id'] for item in result['data']]).to(equal([1, 2]))
+
+ with it('keeps ungrouped and empty Gantt results valid'):
+ xml = (''
+ ''
+ ''
+ ''
+ '')
+ graph = parse_graph(xml)
+ fields = {'name': {'type': 'char'},
+ 'start': {'type': 'date', 'string': 'Start'},
+ 'end': {'type': 'date'}}
+ expect(graph.process([], fields)['data']).to(equal([]))
+ result = graph.process([{'id': 1, 'name': 'Task'}], fields)
+ expect(result['isGroup']).to(be_false)
+ expect(result['data'][0]['type']).to(equal('Start'))
+
+ with it('rejects Gantt without one start and one end'):
+ xml = (''
+ ''
+ '')
+ expect(lambda: parse_graph(xml)).to(raise_error(ValueError))
diff --git a/spec/graph/slider_spec.py b/spec/graph/slider_spec.py
new file mode 100644
index 0000000..086bb9c
--- /dev/null
+++ b/spec/graph/slider_spec.py
@@ -0,0 +1,28 @@
+from mamba import *
+from expects import *
+from ooui.graph import parse_graph
+
+
+with description('Graph Y-axis slider'):
+ with it('leaves old line and bar payloads unchanged without a slider'):
+ fields = {'name': {'type': 'char'}, 'amount': {'type': 'integer'}}
+ values = [{'name': 'January', 'amount': 10}]
+ for graph_type in ('line', 'bar'):
+ xml = (''
+ ''
+ '').format(graph_type)
+ result = parse_graph(xml).process(values, fields)
+ expect(result).not_to(have_key('yAxisOpts'))
+
+ with it('returns slider metadata for line and bar without removing legacy axis props'):
+ fields = {'name': {'type': 'char'}, 'amount': {'type': 'integer', 'string': 'Amount'}}
+ values = [{'name': 'January', 'amount': 10}]
+ for graph_type in ('line', 'bar'):
+ xml = (''
+ ''
+ ''
+ '').format(graph_type)
+ result = parse_graph(xml).process(values, fields)
+ expect(result['yAxisOpts']).to(equal({'mode': 'slider'}))
+ if graph_type == 'line':
+ expect(result['yAxisProps']).to(equal({'mode': 'slider'}))