Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ Gauge styles: `heat` (blue→amber→red), `load` (emerald→amber→red), `emer

Interactive task scheduling with Frappe Gantt. Split-pane layout: task grid
on top, SVG timeline chart on bottom. Drag to reschedule, resize to change
duration, project-colored bars, dependency arrows.
duration, sortable grid columns, project-colored bars, and pinned task styling.

```html
<link rel="stylesheet" href="/sf/vendor/frappe-gantt/frappe-gantt.min.css">
Expand All @@ -236,9 +236,9 @@ var gantt = SF.gantt.create({
viewMode: 'Quarter Day',
splitSizes: [40, 60],
columns: [
{ key: 'name', label: 'Task' },
{ key: 'start', label: 'Start' },
{ key: 'end', label: 'End' },
{ key: 'name', label: 'Task', sortable: true },
{ key: 'start', label: 'Start', sortable: true },
{ key: 'end', label: 'End', sortable: true },
{ key: 'priority', label: 'P', render: function (t) {
return {
unsafeHtml: '<span class="sf-priority-badge priority-' + t.priority + '">P' + t.priority + '</span>',
Expand All @@ -258,6 +258,8 @@ gantt.setTasks([
start: '2026-03-15 09:00',
end: '2026-03-15 10:30',
priority: 1,
projectIndex: 0,
pinned: true,
custom_class: 'project-color-0 priority-1',
dependencies: '',
},
Expand All @@ -277,6 +279,7 @@ gantt.highlightTask('task-1');
```

View modes: `Quarter Day`, `Half Day`, `Day`, `Week`, `Month`.
Sortable headers are opt-in per column via `sortable: true`.

## Backend Adapters

Expand Down
9 changes: 0 additions & 9 deletions css-src/17-gantt-layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,6 @@
border-radius: var(--sf-radius-sm);
}

/* ── Maximized Pane ── */
.sf-gantt-pane.maximized {
position: absolute !important;
inset: 0 !important;
z-index: 50;
height: 100% !important;
width: 100% !important;
}

/* ── Grid Table ── */
.sf-gantt-grid {
height: 100%;
Expand Down
89 changes: 0 additions & 89 deletions css-src/18-gantt-bars.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,6 @@
.gantt .bar-wrapper.priority-2 .bar { opacity: 0.85; }
.gantt .bar-wrapper.priority-3 .bar { opacity: 0.7; }

/* Cross-project dependency arrows */
.gantt .arrow path.cross-project-arrow {
stroke: var(--sf-emerald-500);
stroke-dasharray: 5 3;
stroke-width: 1.4;
}

/* Bar hover glow */
.gantt .bar-wrapper:hover .bar {
filter: brightness(1.12) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.22));
Expand Down Expand Up @@ -58,61 +51,13 @@
width 0.12s var(--sf-ease-out);
}

/* Dragging state */
.gantt .bar-wrapper.dragging {
opacity: 0.88;
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.35));
z-index: 100;
transition: none !important;
}

.gantt .bar-wrapper.dragging .bar {
stroke-width: 2;
stroke: rgba(255, 255, 255, 0.7);
}

body.sf-gantt-dragging {
cursor: grabbing !important;
user-select: none !important;
}

body.sf-gantt-dragging * {
cursor: grabbing !important;
}

/* ── Drag Tooltip ── */
.sf-gantt-drag-tooltip {
position: fixed;
z-index: 9999;
background: rgba(15, 23, 42, 0.92);
color: #f1f5f9;
font-size: 11.5px;
font-family: var(--sf-font-mono);
font-weight: 500;
padding: 5px 10px;
border-radius: 6px;
pointer-events: none;
white-space: nowrap;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.12);
letter-spacing: 0.02em;
display: none;
}

/* ── Pinned Task Indicators ── */
.gantt .bar-wrapper.pinned .bar {
stroke: var(--sf-amber-500);
stroke-width: 2;
stroke-dasharray: 6 3;
}

.sf-gantt-pin-icon {
color: var(--sf-amber-500);
font-size: 11px;
opacity: 0.85;
}

/* ── Highlighted task (pulse from grid click) ── */
.gantt .bar-wrapper.highlighted .bar {
animation: sf-gantt-pulse 0.5s ease-in-out 3;
Expand Down Expand Up @@ -149,40 +94,6 @@ body.sf-gantt-dragging * {
color: var(--sf-gray-500);
}

/* ── Project Badge (grid table) ── */
.sf-project-badge {
display: inline-flex;
align-items: center;
padding: var(--sf-badge-padding-y) var(--sf-badge-padding-x);
padding-left: 20px;
border-radius: var(--sf-badge-radius);
font-size: var(--sf-badge-font-size);
font-weight: var(--sf-badge-font-weight);
position: relative;
background: var(--sf-gray-100);
}

.sf-project-badge::before {
content: '';
position: absolute;
left: 7px;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}

.sf-project-badge.sf-project-0 { color: var(--sf-project-0); background: var(--sf-project-0-light); }
.sf-project-badge.sf-project-1 { color: var(--sf-project-1); background: var(--sf-project-1-light); }
.sf-project-badge.sf-project-2 { color: var(--sf-project-2); background: var(--sf-project-2-light); }
.sf-project-badge.sf-project-3 { color: var(--sf-project-3); background: var(--sf-project-3-light); }
.sf-project-badge.sf-project-4 { color: var(--sf-project-4); background: var(--sf-project-4-light); }
.sf-project-badge.sf-project-5 { color: var(--sf-project-5); background: var(--sf-project-5-light); }
.sf-project-badge.sf-project-6 { color: var(--sf-project-6); background: var(--sf-project-6-light); }
.sf-project-badge.sf-project-7 { color: var(--sf-project-7); background: var(--sf-project-7-light); }

/* ── Gantt Popup ── */
.sf-gantt-popup {
padding: 8px 12px;
Expand Down
91 changes: 72 additions & 19 deletions js-src/14-gantt.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
var mountTarget = null;
var resizeObserver = null;
var tasks = [];
var sortState = { key: null, direction: 'asc' };

// ── Build DOM ──
var wrapper = sf.el('div', { className: 'sf-gantt-split' });
Expand All @@ -43,7 +44,6 @@
var chartHeader = sf.el('div', { className: 'sf-gantt-pane-header' });
chartHeader.appendChild(sf.el('h3', null, config.chartTitle || 'Timeline'));

// View mode selector
var viewControls = sf.el('div', { className: 'sf-gantt-view-controls' });
var viewSelect = sf.el('select', { className: 'sf-gantt-view-select' });
var modes = [
Expand All @@ -59,9 +59,7 @@
viewSelect.appendChild(opt);
});
viewSelect.addEventListener('change', function () {
if (ganttChart) {
ganttChart.change_view_mode(viewSelect.value);
}
if (ganttChart) ganttChart.change_view_mode(viewSelect.value);
});
viewControls.appendChild(viewSelect);

Expand Down Expand Up @@ -111,8 +109,7 @@

ctrl.refresh = function () {
if (ganttChart && tasks.length > 0) {
var frappeTasks = tasksToFrappe(tasks);
ganttChart.refresh(frappeTasks);
ganttChart.refresh(tasksToFrappe(tasks));
}
};

Expand All @@ -124,11 +121,9 @@
};

ctrl.highlightTask = function (taskId) {
// Grid highlight
grid.querySelectorAll('.sf-gantt-row').forEach(function (row) {
row.classList.toggle('selected', row.dataset.taskId === taskId);
});
// Bar highlight
var svg = chartContainer.querySelector('svg');
if (svg) {
svg.querySelectorAll('.bar-wrapper').forEach(function (bw) {
Expand All @@ -153,8 +148,6 @@

return ctrl;

// ── Internal ──

function initSplit() {
if (typeof Split !== 'function') return;
if (splitInstance) {
Expand Down Expand Up @@ -224,12 +217,16 @@
return taskList
.filter(function (t) { return t.start && t.end; })
.map(function (t) {
var customClass = t.custom_class || '';
if (t.pinned) {
customClass = customClass ? customClass + ' pinned' : 'pinned';
}
return {
id: t.id,
name: t.name || t.label || t.id,
start: t.start,
end: t.end,
custom_class: t.custom_class || '',
custom_class: customClass,
dependencies: t.dependencies || '',
};
});
Expand Down Expand Up @@ -273,32 +270,36 @@
function renderGrid(taskList) {
while (grid.firstChild) grid.removeChild(grid.firstChild);
var table = sf.el('table', { className: 'sf-gantt-table' });

// Header
var thead = sf.el('thead');
var headerRow = sf.el('tr');
var columns = config.columns || [
{ key: 'name', label: 'Task' },
{ key: 'start', label: 'Start' },
{ key: 'end', label: 'End' },
];
var sortedTasks = sortTasks(taskList);

var thead = sf.el('thead');
var headerRow = sf.el('tr');
columns.forEach(function (col) {
headerRow.appendChild(sf.el('th', null, col.label));
headerRow.appendChild(buildHeaderCell(col));
});
thead.appendChild(headerRow);
table.appendChild(thead);

// Body
var tbody = sf.el('tbody');
taskList.forEach(function (task) {
sortedTasks.forEach(function (task) {
var rowClasses = ['sf-gantt-row'];
if (task.custom_class) rowClasses.push(task.custom_class);
if (task.projectIndex != null) rowClasses.push('sf-project-' + task.projectIndex);

var tr = sf.el('tr', {
className: 'sf-gantt-row' + (task.custom_class ? ' ' + task.custom_class : ''),
className: rowClasses.join(' '),
dataset: { taskId: task.id },
onClick: function () {
ctrl.highlightTask(task.id);
if (config.onTaskClick) config.onTaskClick(task);
},
});

columns.forEach(function (col) {
var td = sf.el('td');
if (col.key === 'name') {
Expand All @@ -316,12 +317,64 @@
}
tr.appendChild(td);
});

tbody.appendChild(tr);
});
table.appendChild(tbody);
grid.appendChild(table);
}

function buildHeaderCell(col) {
if (!col.sortable) {
return sf.el('th', null, col.label);
}

var isCurrent = sortState.key === col.key;
var th = sf.el('th', {
className: 'sortable' + (isCurrent ? ' active' : ''),
role: 'button',
tabIndex: 0,
'aria-sort': isCurrent ? (sortState.direction === 'asc' ? 'ascending' : 'descending') : 'none',
});
th.appendChild(document.createTextNode(col.label));
th.appendChild(sf.el('span', { className: 'sort-icon' }, isCurrent ? (sortState.direction === 'asc' ? '▲' : '▼') : ''));

sf.bindActivation(th, function () {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace undefined activation helper used by sortable headers

Any integration that follows the new README and sets sortable: true will fail here during setTasks(): I searched the repo and there is no sf.bindActivation implementation in either js-src or the built static/sf/sf.js, so buildHeaderCell() throws before the grid renders. That makes the newly added sortable-column path unusable until this is replaced with a real event binding or the helper is added.

Useful? React with 👍 / 👎.

if (sortState.key === col.key) {
sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc';
} else {
sortState.key = col.key;
sortState.direction = 'asc';
}
renderGrid(tasks);
});

return th;
}

function sortTasks(taskList) {
if (!sortState.key) return taskList.slice();
var sorted = taskList.slice();
sorted.sort(function (a, b) {
var aVal = sortValue(a[sortState.key], sortState.key);
var bVal = sortValue(b[sortState.key], sortState.key);
if (aVal === bVal) return 0;
if (sortState.direction === 'asc') return aVal < bVal ? -1 : 1;
return aVal > bVal ? -1 : 1;
});
return sorted;
}

function sortValue(value, key) {
if (value == null) return '';
if (key === 'start' || key === 'end') {
var parsed = Date.parse(value);
return isNaN(parsed) ? String(value).toLowerCase() : parsed;
}
if (typeof value === 'number') return value;
return String(value).toLowerCase();
}

function defaultPopup(task) {
var t = tasks.find(function (x) { return x.id === task.id; });
if (!t) return '';
Expand Down
Loading
Loading