Gantt chart view to one board view menu Swimlanes/Lists/Calendar/Gantt.

Thanks to xet7 !

Fixes #2870
This commit is contained in:
Lauri Ojansivu 2025-12-22 16:51:10 +02:00
parent 1790918006
commit f34e4c0e36
10 changed files with 511 additions and 112 deletions

View file

@ -49,6 +49,8 @@ template(name="boardBody")
+listsGroup(currentBoard)
else if isViewCalendar
+calendarView
else if isViewGantt
+ganttView
else
// Default view - show swimlanes if they exist, otherwise show lists
if hasSwimlanes
@ -64,3 +66,10 @@ template(name="calendarView")
if currentCard
+cardDetails(currentCard)
+fullcalendar(calendarOptions)
template(name="ganttView")
if isViewGantt
.gantt-view.swimlane
if currentCard
+cardDetails(currentCard)
.gantt-container
#gantt-chart

View file

@ -5,6 +5,7 @@ import { boardConverter } from '/client/lib/boardConverter';
import { migrationManager } from '/client/lib/migrationManager';
import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
import { migrationProgressManager } from '/client/components/migrationProgress';
import { formatDateByUserPreference } from '/imports/lib/dateUtils';
import Swimlanes from '/models/swimlanes';
import Lists from '/models/lists';
@ -978,6 +979,19 @@ BlazeComponent.extendComponent({
return boardView === 'board-view-cal';
},
isViewGantt() {
const currentUser = ReactiveCache.getCurrentUser();
let boardView;
if (currentUser) {
boardView = (currentUser.profile || {}).boardView;
} else {
boardView = window.localStorage.getItem('boardView');
}
return boardView === 'board-view-gantt';
},
hasSwimlanes() {
const currentBoard = Utils.getCurrentBoard();
if (!currentBoard) {
@ -1408,3 +1422,263 @@ BlazeComponent.extendComponent({
}
},
}).register('calendarView');
/**
* Gantt View Component
* Displays cards as a Gantt chart with start/due dates
*/
BlazeComponent.extendComponent({
template() {
return 'ganttView';
},
onCreated() {
this.autorun(() => {
const board = Utils.getCurrentBoard();
if (board) {
// Subscribe to cards for the current board
this.subscribe('allCards', board._id);
this.subscribe('allLists', board._id);
}
});
},
onRendered() {
this.autorun(() => {
const board = Utils.getCurrentBoard();
if (board && this.subscriptionsReady()) {
this.renderGanttChart();
}
});
},
renderGanttChart() {
const board = Utils.getCurrentBoard();
if (!board) return;
const ganttContainer = document.getElementById('gantt-chart');
if (!ganttContainer) return;
// Clear previous content
ganttContainer.innerHTML = '';
// Get all cards for the board
const cards = Cards.find({ boardId: board._id }, { sort: { startAt: 1, dueAt: 1 } }).fetch();
if (cards.length === 0) {
ganttContainer.innerHTML = `<p style="padding: 20px; text-align: center; color: #999;">${TAPi18n.__('no-cards-in-gantt')}</p>`;
return;
}
// Create a weekly HTML gantt view
this.createWeeklyGanttView(cards, ganttContainer);
},
createWeeklyGanttView(cards, container) {
const today = new Date();
const currentUser = ReactiveCache.getCurrentUser && ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
// Helpers to compute ISO week and start/end of week
const getISOWeekInfo = d => {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const dayNum = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
const week = Math.ceil((((date - yearStart) / 86400000) + 1) / 7);
return { year: date.getUTCFullYear(), week };
};
const startOfISOWeek = d => {
const date = new Date(d);
const day = date.getDay() || 7; // Sunday -> 7
if (day !== 1) date.setDate(date.getDate() - (day - 1));
date.setHours(0,0,0,0);
return date;
};
// Collect weeks that have any dates on cards
const weeksMap = new Map(); // key: `${year}-W${week}` -> { year, week, start }
const relevantCards = cards.filter(c => c.receivedAt || c.startAt || c.dueAt || c.endAt);
relevantCards.forEach(card => {
['receivedAt','startAt','dueAt','endAt'].forEach(field => {
if (card[field]) {
const dt = new Date(card[field]);
const info = getISOWeekInfo(dt);
const key = `${info.year}-W${info.week}`;
if (!weeksMap.has(key)) {
weeksMap.set(key, { year: info.year, week: info.week, start: startOfISOWeek(dt) });
}
}
});
});
// Sort weeks by start ascending (oldest first)
const weeks = Array.from(weeksMap.values()).sort((a,b) => a.start - b.start);
// Weekday labels
const weekdayKeys = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'];
const weekdayLabels = weekdayKeys.map(k => TAPi18n.__(k));
// Build HTML for all week tables
let html = '';
weeks.forEach(weekInfo => {
const weekStart = new Date(weekInfo.start);
const weekDates = Array.from({length:7}, (_,i) => {
const d = new Date(weekStart);
d.setDate(d.getDate() + i);
d.setHours(0,0,0,0);
return d;
});
// Table header
html += '<table class="gantt-table">';
html += '<thead>';
html += '<tr>';
const taskHeader = `${TAPi18n.__('task')} ${TAPi18n.__('predicate-week')} ${weekInfo.week}`;
html += `<th>${taskHeader}</th>`;
weekdayLabels.forEach((lbl, idx) => {
const formattedDate = formatDateByUserPreference(weekDates[idx], dateFormat, false);
html += `<th>${formattedDate} ${lbl}</th>`;
});
html += '</tr></thead>';
// Rows: include cards that have any date in this week
html += '<tbody>';
relevantCards.forEach(card => {
const cardDates = {
receivedAt: card.receivedAt ? new Date(card.receivedAt) : null,
startAt: card.startAt ? new Date(card.startAt) : null,
dueAt: card.dueAt ? new Date(card.dueAt) : null,
endAt: card.endAt ? new Date(card.endAt) : null,
};
const isInWeek = Object.values(cardDates).some(dt => dt && getISOWeekInfo(dt).week === weekInfo.week && getISOWeekInfo(dt).year === weekInfo.year);
if (!isInWeek) return;
// Row header cell (task title)
html += '<tr>';
html += `<td class="js-gantt-task-cell" data-card-id="${card._id}" title="${card.title}">${card.title}</td>`;
// Weekday cells with icons/colors only on exact matching dates
weekDates.forEach((dayDate, idx) => {
let cellContent = '';
let cellClass = '';
let cellStyle = '';
let cellTitle = '';
let cellDateType = '';
// Highlight today and weekends
const isToday = dayDate.toDateString() === today.toDateString();
if (isToday) {
cellClass += ' ganttview-today';
cellStyle += 'background-color: #fcf8e3 !important;';
}
const isWeekend = idx >= 5; // Saturday/Sunday
if (isWeekend) {
cellClass += ' ganttview-weekend';
if (!isToday) cellStyle += 'background-color: #efefef !important;';
}
// Match specific date types
if (cardDates.receivedAt && cardDates.receivedAt.toDateString() === dayDate.toDateString()) {
cellContent = '📥';
cellStyle = 'background-color: #dbdbdb !important; color: #000 !important; font-size: 18px !important; font-weight: bold !important;';
cellTitle = TAPi18n.__('card-received');
cellDateType = 'received';
}
if (cardDates.startAt && cardDates.startAt.toDateString() === dayDate.toDateString()) {
cellContent = '🚀';
cellStyle = 'background-color: #90ee90 !important; color: #000 !important; font-size: 18px !important; font-weight: bold !important;';
cellTitle = TAPi18n.__('card-start');
cellDateType = 'start';
}
if (cardDates.dueAt && cardDates.dueAt.toDateString() === dayDate.toDateString()) {
cellContent = '⏰';
cellStyle = 'background-color: #ffd700 !important; color: #000 !important; font-size: 18px !important; font-weight: bold !important;';
cellTitle = TAPi18n.__('card-due');
cellDateType = 'due';
}
if (cardDates.endAt && cardDates.endAt.toDateString() === dayDate.toDateString()) {
cellContent = '🏁';
cellStyle = 'background-color: #ffb3b3 !important; color: #000 !important; font-size: 18px !important; font-weight: bold !important;';
cellTitle = TAPi18n.__('card-end');
cellDateType = 'end';
}
if (cellDateType) {
cellClass += ' js-gantt-date-icon';
}
const cellDataAttrs = cellDateType ? ` data-card-id="${card._id}" data-date-type="${cellDateType}"` : '';
html += `<td class="${cellClass}" style="${cellStyle}" title="${cellTitle}"${cellDataAttrs}>${cellContent}</td>`;
});
// Close row
html += '</tr>';
});
// Close section for this week
html += '</tbody></table>';
});
container.innerHTML = html;
// Add click handlers
const taskCells = container.querySelectorAll('.js-gantt-task-cell');
taskCells.forEach(cell => {
cell.addEventListener('click', (e) => {
const cardId = e.currentTarget.dataset.cardId;
const card = ReactiveCache.getCard(cardId);
if (!card) return;
// Scroll the gantt container and viewport to top so the card details are visible
if (container && typeof container.scrollIntoView === 'function') {
container.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
if (typeof window !== 'undefined' && typeof window.scrollTo === 'function') {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
const contentEl = document.getElementById('content');
if (contentEl && typeof contentEl.scrollTo === 'function') {
contentEl.scrollTo({ top: 0, behavior: 'smooth' });
}
// Open card the same way as clicking a minicard - set currentCard session
// This shows the full card details overlay, not a popup
Session.set('currentCard', cardId);
});
});
// Date icon click handlers: open the same edit popups as in swimlane cards
const dateIconCells = container.querySelectorAll('.js-gantt-date-icon');
dateIconCells.forEach(cell => {
cell.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const cardId = e.currentTarget.dataset.cardId;
const dateType = e.currentTarget.dataset.dateType;
const card = ReactiveCache.getCard(cardId);
if (!card || !dateType) return;
const popupMap = {
received: 'editCardReceivedDate',
start: 'editCardStartDate',
due: 'editCardDueDate',
end: 'editCardEndDate',
};
const popupName = popupMap[dateType];
if (!popupName || !Popup || typeof Popup.open !== 'function') return;
const openFn = Popup.open(popupName);
// Supply the card as data context for the popup
openFn.call({ currentData: () => card }, e, { dataContextIfCurrentDataIsUndefined: card });
});
});
},
isViewGantt() {
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser) {
return (currentUser.profile || {}).boardView === 'board-view-gantt';
} else {
return window.localStorage.getItem('boardView') === 'board-view-gantt';
}
},
}).register('ganttView');

View file

@ -121,6 +121,8 @@ template(name="boardHeaderBar")
| 📋
if $eq boardView 'board-view-cal'
| 📅
if $eq boardView 'board-view-gantt'
| 📊
if canModifyBoard
a.board-header-btn.js-multiselection-activate(
@ -208,6 +210,13 @@ template(name="boardChangeViewPopup")
| {{_ 'board-view-cal'}}
if $eq Utils.boardView "board-view-cal"
| ✅
li
with "board-view-gantt"
a.js-open-gantt-view
| 📊
| {{_ 'board-view-gantt'}}
if $eq Utils.boardView "board-view-gantt"
| ✅
template(name="createBoard")
form

View file

@ -208,6 +208,10 @@ Template.boardChangeViewPopup.events({
Utils.setBoardView('board-view-cal');
Popup.back();
},
'click .js-open-gantt-view'() {
Utils.setBoardView('board-view-gantt');
Popup.back();
},
});
const CreateBoard = BlazeComponent.extendComponent({

View file

@ -0,0 +1,178 @@
/* Gantt View Styles */
.gantt-view {
width: 100%;
height: auto;
overflow: visible;
background-color: #fff;
}
.gantt-view.swimlane {
background-color: #fff;
padding: 10px;
}
.gantt-container {
overflow-x: auto;
overflow-y: visible;
background-color: #fff;
display: block;
width: 100%;
}
.gantt-container table,
.gantt-table {
border-collapse: collapse;
width: 100%;
min-width: 800px;
border: 2px solid #666;
font-family: sans-serif;
font-size: 13px;
background-color: #fff;
}
.gantt-container thead {
background-color: #e8e8e8;
border-bottom: 2px solid #666;
font-weight: bold;
position: sticky;
top: 0;
z-index: 10;
}
.gantt-container thead th,
.gantt-container thead tr > td:first-child {
border-right: 2px solid #666;
padding: 4px; /* half of 8px */
width: 100px; /* half of 200px */
text-align: left;
font-weight: bold;
background-color: #e8e8e8;
min-width: 100px; /* half of 200px */
}
.gantt-container thead td {
border-right: 1px solid #999;
padding: 2px 1px; /* half */
text-align: center;
background-color: #f5f5f5;
font-size: 11px;
min-width: 15px; /* half of 30px */
font-weight: bold;
height: auto;
line-height: 1.2;
white-space: normal;
word-break: break-word;
}
.gantt-container tbody tr {
border-bottom: 1px solid #999;
height: 32px;
}
.gantt-container tbody tr:hover {
background-color: #f9f9f9;
}
.gantt-container tbody tr:hover td {
background-color: #f9f9f9 !important;
}
.gantt-container tbody td {
border-right: 1px solid #ccc;
padding: 1px; /* half */
text-align: center;
min-width: 15px; /* half of 30px */
height: 32px;
vertical-align: middle;
line-height: 28px;
background-color: #ffffff;
font-size: 18px;
font-weight: bold;
}
.gantt-container tbody td:nth-child(even) {
background-color: #fafafa;
}
.gantt-container tbody td:first-child {
border-right: 2px solid #666;
padding: 4px; /* half of 8px */
font-weight: 500;
cursor: pointer;
background-color: #fafafa !important;
text-align: left;
width: 100px; /* half of 200px */
min-width: 100px; /* half of 200px */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
height: auto;
line-height: normal;
}
.gantt-container tbody td:first-child:hover {
background-color: #f0f0f0 !important;
text-decoration: underline;
}
.js-gantt-task-cell {
cursor: pointer;
}
.js-gantt-date-icon {
cursor: pointer;
}
.gantt-container .ganttview-weekend {
background-color: #efefef;
}
.gantt-container .ganttview-today {
background-color: #fcf8e3;
border-right: 2px solid #ffb347;
}
/* Task bar styling - VERY VISIBLE */
.gantt-container tbody td.ganttview-block {
background-color: #4CAF50 !important;
color: #fff !important;
font-size: 18px !important;
font-weight: bold !important;
padding: 2px !important;
border-radius: 2px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.gantt-container table {
font-size: 11px;
}
.gantt-container thead td {
min-width: 20px;
padding: 2px;
}
.gantt-container tbody td {
min-width: 20px;
padding: 1px;
height: 20px;
}
.gantt-container tbody td:first-child {
width: 100px;
font-size: 12px;
}
}
/* Print styles */
@media print {
.gantt-container {
overflow: visible;
}
.gantt-container table {
page-break-inside: avoid;
}
}

View file

@ -297,7 +297,23 @@ BlazeComponent.extendComponent({
{
...events,
'click .js-close-card-details'() {
Utils.goBoardId(this.data().boardId);
// Get board ID from either the card data or current board in session
const card = this.currentData() || this.data();
const boardId = (card && card.boardId) || Utils.getCurrentBoard()._id;
if (boardId) {
// Clear the current card session to close the card
Session.set('currentCard', null);
// Navigate back to board without card
const board = ReactiveCache.getBoard(boardId);
if (board) {
FlowRouter.go('board', {
id: board._id,
slug: board.slug,
});
}
}
},
'click .js-copy-link'(event) {
event.preventDefault();

View file

@ -212,6 +212,11 @@ window.Popup = new (class {
if (Utils.isMiniScreen()) return { left: 0, top: 0 };
// If the opener element is missing (e.g., programmatic open), fallback to viewport origin
if (!$element || $element.length === 0) {
return { left: 10, top: 10, maxHeight: $(window).height() - 20 };
}
const offset = $element.offset();
// Calculate actual popup width based on CSS: min(380px, 55vw)
const viewportWidth = $(window).width();

View file

@ -264,6 +264,9 @@ Utils = {
} else if (view === 'board-view-cal') {
window.localStorage.setItem('boardView', 'board-view-cal'); //true
Utils.reload();
} else if (view === 'board-view-gantt') {
window.localStorage.setItem('boardView', 'board-view-gantt'); //true
Utils.reload();
} else {
window.localStorage.setItem('boardView', 'board-view-swimlanes'); //true
Utils.reload();
@ -289,6 +292,8 @@ Utils = {
return 'board-view-lists';
} else if (window.localStorage.getItem('boardView') === 'board-view-cal') {
return 'board-view-cal';
} else if (window.localStorage.getItem('boardView') === 'board-view-gantt') {
return 'board-view-gantt';
} else {
window.localStorage.setItem('boardView', 'board-view-swimlanes'); //true
Utils.reload();

View file

@ -1,122 +1,20 @@
# What is this?
# Gantt chart
Original WeKan is MIT-licensed software.
This new Gantt feature was added to MIT WeKan 2025-12-22 at https://github.com/wekan/wekan
This different Gantt version here currently uses Gantt chart component that has GPL license, so this Wekan Gantt version is GPL licensed.
At "All Boards" page, click board to open one board view. There, Gantt is at top dropdown menu Swimlanes/Lists/Calendar/Gantt.
Sometime later if that GPL licensed Gantt chart component will be changed to MIT licensed one, then that original MIT-licensed WeKan will get Gantt feature, and maybe this GPL version will be discontinued.
Gantt shows all dates, according to selected date format at opened card: Received Start Due End.
# How to use
Gantt dates are shown for every week where exist dates at the current opened board.
[Source](https://github.com/wekan/wekan/issues/2870#issuecomment-721690105)
You can click task name to open card.
At cards, both Start and End dates should be set (not Due date) for the tasks to be displayed.
You can click any date icon to change that date, like: Received Start Due End.
# Funding for more features?
# Old WeKan Gantt GPL
You can fund development of more features of Gantt at https://wekan.fi/commercial-support, like for example:
- more of day/week/month/year views
- drag etc
# Issue
https://github.com/wekan/wekan/issues/2870
# Install
Wekan GPLv2 Gantt version:
- https://github.com/wekan/wekan-gantt-gpl
- https://snapcraft.io/wekan-gantt-gpl
- https://hub.docker.com/repository/docker/wekanteam/wekan-gantt-gpl
- https://quay.io/wekan/wekan-gantt-gpl
## How to install Snap
[Like Snap install](https://github.com/wekan/wekan-snap/wiki/Install) but with commands like:
```
sudo snap install wekan-gantt-gpl
sudo snap set wekan-gantt-gpl root-url='http://localhost'
sudo snap set wekan-gantt-gpl port='80'
```
Stopping all:
```
sudo snap stop wekan-gantt-gpl
```
Stopping only some part:
```
sudo snap stop wekan-gantt-gpl.caddy
sudo snap stop wekan-gantt-gpl.mongodb
sudo snap stop wekan-gantt-gpl.wekan
```
## Changing from Wekan to Wekan Gantt GPL
1) Install newest MongoDB to have also mongorestore available
2) Backup database and settings:
```
sudo snap stop wekan.wekan
mongodump --port 27019
snap get wekan > snap-set.sh
sudo snap remove wekan
sudo snap install wekan-gantt-gpl
sudo snap stop wekan-gantt-gpl.wekan
nano snap-set.sh
```
Then edit that textfile so all commands will be similar to this:
```
sudo snap set wekan-gantt-gpl root-url='https://example.com'
```
And run settings:
```
chmod +x snap-set.sh
./snap-set.sh
sudo snap start wekan-gantt-gpl.wekan
```
## Changing from Wekan Gantt GPL to Wekan
1) Install newest MongoDB to have also mongorestore available
2) Backup database and settings:
```
sudo snap stop wekan-gantt-gpl.wekan
mongodump --port 27019
snap get wekan-gantt-gpl > snap-set.sh
sudo snap remove wekan-gantt-gpl
sudo snap install wekan
sudo snap stop wekan.wekan
nano snap-set.sh
```
Then edit that textfile so all commands will be similar to this:
```
sudo snap set wekan root-url='https://example.com'
```
And run settings:
```
chmod +x snap-set.sh
./snap-set.sh
sudo snap start wekan.wekan
```
Previous GPLv2 WeKan Gantt is deprecated https://github.com/wekan/wekan-gantt-gpl
# UCS

View file

@ -393,6 +393,7 @@ Users.attachSchema(
'board-view-swimlanes',
'board-view-lists',
'board-view-cal',
'board-view-gantt',
],
},
'profile.listSortBy': {