diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts
index baabea1381a..17ad9f54c4a 100644
--- a/models/tracker/src/index.ts
+++ b/models/tracker/src/index.ts
@@ -196,7 +196,9 @@ function defineFilters (builder: Builder): void {
key: 'milestone',
component: view.component.ObjectFilter,
showNested: false
- }
+ },
+ 'startDate',
+ 'dueDate'
],
ignoreKeys: ['number', 'estimation', 'attachedTo'],
getVisibleFilters: tracker.function.GetVisibleFilters
diff --git a/models/tracker/src/viewlets.ts b/models/tracker/src/viewlets.ts
index 509e00d7d0e..7ed91988d37 100644
--- a/models/tracker/src/viewlets.ts
+++ b/models/tracker/src/viewlets.ts
@@ -212,6 +212,56 @@ export function issueConfig (
]
}
+export function ganttViewOptions (): ViewOptionsModel {
+ // This read-only Gantt viewlet intentionally does not advertise group-by or
+ // show-colors — the canvas does not honour them yet. The two sidebar-column
+ // toggles below ARE wired up to GanttSidebar.
+ return {
+ groupBy: [],
+ orderBy: [
+ ['startDate', SortingOrder.Ascending],
+ ['rank', SortingOrder.Ascending],
+ ['dueDate', SortingOrder.Ascending]
+ ],
+ other: [
+ {
+ key: 'ganttShowIssueCode',
+ type: 'toggle',
+ defaultValue: false,
+ actionTarget: 'display',
+ label: tracker.string.GanttShowIssueCode
+ },
+ {
+ key: 'ganttShowTitle',
+ type: 'toggle',
+ defaultValue: true,
+ actionTarget: 'display',
+ label: tracker.string.GanttShowTitle
+ },
+ {
+ key: 'ganttShowStatus',
+ type: 'toggle',
+ defaultValue: true,
+ actionTarget: 'display',
+ label: tracker.string.GanttShowStatus
+ }
+ ]
+ }
+}
+
+export function ganttConfig (): BuildModelKey[] {
+ // Minimal config — Gantt drives its own column layout.
+ return [
+ {
+ key: '',
+ presenter: tracker.component.PriorityEditor,
+ label: tracker.string.Priority,
+ props: { kind: 'list', size: 'small' }
+ },
+ { key: '', presenter: tracker.component.IssuePresenter, label: tracker.string.Issue }
+ ]
+}
+
export function defineViewlets (builder: Builder): void {
builder.createDoc(
view.class.ViewletDescriptor,
@@ -224,6 +274,17 @@ export function defineViewlets (builder: Builder): void {
tracker.viewlet.Kanban
)
+ builder.createDoc(
+ view.class.ViewletDescriptor,
+ core.space.Model,
+ {
+ label: tracker.string.Gantt,
+ icon: tracker.icon.Gantt,
+ component: tracker.component.GanttView
+ },
+ tracker.viewlet.Gantt
+ )
+
builder.createDoc(
view.class.Viewlet,
core.space.Model,
@@ -501,6 +562,23 @@ export function defineViewlets (builder: Builder): void {
tracker.viewlet.IssueKanban
)
+ // Gantt is registered AFTER List + Kanban so List remains the default
+ // viewlet (ViewletSelector falls back to viewlets[0] when no preference
+ // is saved). Putting Gantt last avoids surprising users with an empty
+ // canvas on first visit.
+ builder.createDoc(
+ view.class.Viewlet,
+ core.space.Model,
+ {
+ attachTo: tracker.class.Issue,
+ descriptor: tracker.viewlet.Gantt,
+ viewOptions: ganttViewOptions(),
+ configOptions: { strict: true, hiddenKeys: ['title'] },
+ config: ganttConfig()
+ },
+ tracker.viewlet.IssueGantt
+ )
+
const componentListViewOptions: ViewOptionsModel = {
groupBy: ['lead', 'createdBy', 'modifiedBy'],
orderBy: [
diff --git a/plugins/tracker-assets/lang/cs.json b/plugins/tracker-assets/lang/cs.json
index dea411668af..563b354f258 100644
--- a/plugins/tracker-assets/lang/cs.json
+++ b/plugins/tracker-assets/lang/cs.json
@@ -283,7 +283,26 @@
"UnsetParentIssue": "Odebrat nadřazený úkol",
"ForbidCreateProjectPermission": "Zakázat vytvoření projektu",
"ForbidCreateProjectPermissionDescription": "Zakazuje uživatelům vytvářet nové projekty",
- "AllowCreatingIssues": "Povolit vytváření úkolů"
+ "AllowCreatingIssues": "Povolit vytváření úkolů",
+ "Day": "Day",
+ "Week": "Week",
+ "Month": "Month",
+ "Quarter": "Quarter",
+ "Gantt": "Gantt",
+ "GanttShowIssueCode": "Show issue code",
+ "GanttShowTitle": "Show title",
+ "GanttShowStatus": "Show status",
+ "GanttToday": "Today",
+ "GanttJumpToStart": "Jump to start",
+ "GanttJumpToEnd": "Jump to end",
+ "GanttJumpToDate": "Jump to date",
+ "GanttPreviousPeriod": "Previous period",
+ "GanttNextPeriod": "Next period",
+ "GanttScrollLeftToBar": "Scroll left to bar",
+ "GanttScrollRightToBar": "Scroll right to bar",
+ "GanttExpand": "Expand",
+ "GanttCollapse": "Collapse"
+
},
"status": {}
}
diff --git a/plugins/tracker-assets/lang/de.json b/plugins/tracker-assets/lang/de.json
index d1e698c9c9a..8d990bb71b1 100644
--- a/plugins/tracker-assets/lang/de.json
+++ b/plugins/tracker-assets/lang/de.json
@@ -293,7 +293,26 @@
"UnsetParentIssue": "Übergeordnete Aufgabe entfernen",
"ForbidCreateProjectPermission": "Projekterstellung verbieten",
"ForbidCreateProjectPermissionDescription": "Verbietet Benutzern das Erstellen neuer Projekte",
- "AllowCreatingIssues": "Erstellen von Aufgaben erlauben"
+ "AllowCreatingIssues": "Erstellen von Issues erlauben",
+ "Day": "Tag",
+ "Week": "Woche",
+ "Month": "Monat",
+ "Quarter": "Quartal",
+ "Gantt": "Gantt",
+ "GanttShowIssueCode": "Issue-Code anzeigen",
+ "GanttShowTitle": "Titel anzeigen",
+ "GanttShowStatus": "Status anzeigen",
+ "GanttToday": "Heute",
+ "GanttJumpToStart": "Zum Anfang springen",
+ "GanttJumpToEnd": "Zum Ende springen",
+ "GanttJumpToDate": "Zu Datum springen",
+ "GanttPreviousPeriod": "Vorheriger Zeitraum",
+ "GanttNextPeriod": "Nächster Zeitraum",
+ "GanttScrollLeftToBar": "Nach links zum Balken scrollen",
+ "GanttScrollRightToBar": "Nach rechts zum Balken scrollen",
+ "GanttExpand": "Ausklappen",
+ "GanttCollapse": "Einklappen"
+
},
"status": {}
}
diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json
index 3d856a20031..5cd3cfbf7c9 100644
--- a/plugins/tracker-assets/lang/en.json
+++ b/plugins/tracker-assets/lang/en.json
@@ -293,7 +293,25 @@
"UnsetParentIssue": "Unset parent issue",
"ForbidCreateProjectPermission": "Forbid create project",
"ForbidCreateProjectPermissionDescription": "Forbid users creating new projects",
- "AllowCreatingIssues": "Allow creating issues"
+ "AllowCreatingIssues": "Allow creating issues",
+ "Day": "Day",
+ "Week": "Week",
+ "Month": "Month",
+ "Quarter": "Quarter",
+ "Gantt": "Gantt",
+ "GanttShowIssueCode": "Show issue code",
+ "GanttShowTitle": "Show title",
+ "GanttShowStatus": "Show status",
+ "GanttToday": "Today",
+ "GanttJumpToStart": "Jump to start",
+ "GanttJumpToEnd": "Jump to end",
+ "GanttJumpToDate": "Jump to date",
+ "GanttPreviousPeriod": "Previous period",
+ "GanttNextPeriod": "Next period",
+ "GanttScrollLeftToBar": "Scroll left to bar",
+ "GanttScrollRightToBar": "Scroll right to bar",
+ "GanttExpand": "Expand",
+ "GanttCollapse": "Collapse"
},
"status": {}
}
diff --git a/plugins/tracker-assets/lang/es.json b/plugins/tracker-assets/lang/es.json
index ed9795ef41b..b59025c1f35 100644
--- a/plugins/tracker-assets/lang/es.json
+++ b/plugins/tracker-assets/lang/es.json
@@ -276,7 +276,26 @@
"UnsetParentIssue": "Unset parent issue",
"ForbidCreateProjectPermission": "Prohibir crear proyecto",
"ForbidCreateProjectPermissionDescription": "Prohíbe a los usuarios crear nuevos proyectos",
- "AllowCreatingIssues": "Permitir crear incidencias"
+ "AllowCreatingIssues": "Permitir crear incidencias",
+ "Day": "Day",
+ "Week": "Week",
+ "Month": "Month",
+ "Quarter": "Quarter",
+ "Gantt": "Gantt",
+ "GanttShowIssueCode": "Show issue code",
+ "GanttShowTitle": "Show title",
+ "GanttShowStatus": "Show status",
+ "GanttToday": "Today",
+ "GanttJumpToStart": "Jump to start",
+ "GanttJumpToEnd": "Jump to end",
+ "GanttJumpToDate": "Jump to date",
+ "GanttPreviousPeriod": "Previous period",
+ "GanttNextPeriod": "Next period",
+ "GanttScrollLeftToBar": "Scroll left to bar",
+ "GanttScrollRightToBar": "Scroll right to bar",
+ "GanttExpand": "Expand",
+ "GanttCollapse": "Collapse"
+
},
"status": {}
}
diff --git a/plugins/tracker-assets/lang/fr.json b/plugins/tracker-assets/lang/fr.json
index daa7f4eaaba..0aea0548503 100644
--- a/plugins/tracker-assets/lang/fr.json
+++ b/plugins/tracker-assets/lang/fr.json
@@ -276,7 +276,26 @@
"UnsetParentIssue": "Désélectionner l'issue parent",
"ForbidCreateProjectPermission": "Interdire la création de projet",
"ForbidCreateProjectPermissionDescription": "Interdit aux utilisateurs de créer de nouveaux projets",
- "AllowCreatingIssues": "Autoriser la création d'issues"
+ "AllowCreatingIssues": "Autoriser la création d'issues",
+ "Day": "Day",
+ "Week": "Week",
+ "Month": "Month",
+ "Quarter": "Quarter",
+ "Gantt": "Gantt",
+ "GanttShowIssueCode": "Show issue code",
+ "GanttShowTitle": "Show title",
+ "GanttShowStatus": "Show status",
+ "GanttToday": "Today",
+ "GanttJumpToStart": "Jump to start",
+ "GanttJumpToEnd": "Jump to end",
+ "GanttJumpToDate": "Jump to date",
+ "GanttPreviousPeriod": "Previous period",
+ "GanttNextPeriod": "Next period",
+ "GanttScrollLeftToBar": "Scroll left to bar",
+ "GanttScrollRightToBar": "Scroll right to bar",
+ "GanttExpand": "Expand",
+ "GanttCollapse": "Collapse"
+
},
"status": {}
}
diff --git a/plugins/tracker-assets/lang/it.json b/plugins/tracker-assets/lang/it.json
index 7fe2285f261..112887ee43f 100644
--- a/plugins/tracker-assets/lang/it.json
+++ b/plugins/tracker-assets/lang/it.json
@@ -276,7 +276,26 @@
"UnsetParentIssue": "Annulla l'issue genitore",
"ForbidCreateProjectPermission": "Vieta creazione progetto",
"ForbidCreateProjectPermissionDescription": "Vieta agli utenti di creare nuovi progetti",
- "AllowCreatingIssues": "Consenti la creazione di issue"
+ "AllowCreatingIssues": "Consenti la creazione di issue",
+ "Day": "Day",
+ "Week": "Week",
+ "Month": "Month",
+ "Quarter": "Quarter",
+ "Gantt": "Gantt",
+ "GanttShowIssueCode": "Show issue code",
+ "GanttShowTitle": "Show title",
+ "GanttShowStatus": "Show status",
+ "GanttToday": "Today",
+ "GanttJumpToStart": "Jump to start",
+ "GanttJumpToEnd": "Jump to end",
+ "GanttJumpToDate": "Jump to date",
+ "GanttPreviousPeriod": "Previous period",
+ "GanttNextPeriod": "Next period",
+ "GanttScrollLeftToBar": "Scroll left to bar",
+ "GanttScrollRightToBar": "Scroll right to bar",
+ "GanttExpand": "Expand",
+ "GanttCollapse": "Collapse"
+
},
"status": {}
}
diff --git a/plugins/tracker-assets/lang/ja.json b/plugins/tracker-assets/lang/ja.json
index 34426b09875..735be7a01ee 100644
--- a/plugins/tracker-assets/lang/ja.json
+++ b/plugins/tracker-assets/lang/ja.json
@@ -276,7 +276,26 @@
"UnsetParentIssue": "親イシューの設定を解除",
"ForbidCreateProjectPermission": "プロジェクト作成禁止",
"ForbidCreateProjectPermissionDescription": "ユーザーが新しいプロジェクトを作成することを禁止します",
- "AllowCreatingIssues": "イシューの作成を許可"
+ "AllowCreatingIssues": "イシューの作成を許可",
+ "Day": "Day",
+ "Week": "Week",
+ "Month": "Month",
+ "Quarter": "Quarter",
+ "Gantt": "Gantt",
+ "GanttShowIssueCode": "Show issue code",
+ "GanttShowTitle": "Show title",
+ "GanttShowStatus": "Show status",
+ "GanttToday": "Today",
+ "GanttJumpToStart": "Jump to start",
+ "GanttJumpToEnd": "Jump to end",
+ "GanttJumpToDate": "Jump to date",
+ "GanttPreviousPeriod": "Previous period",
+ "GanttNextPeriod": "Next period",
+ "GanttScrollLeftToBar": "Scroll left to bar",
+ "GanttScrollRightToBar": "Scroll right to bar",
+ "GanttExpand": "Expand",
+ "GanttCollapse": "Collapse"
+
},
"status": {}
}
diff --git a/plugins/tracker-assets/lang/ko.json b/plugins/tracker-assets/lang/ko.json
index 32b4b4c75c6..a0483b08ed0 100644
--- a/plugins/tracker-assets/lang/ko.json
+++ b/plugins/tracker-assets/lang/ko.json
@@ -276,7 +276,25 @@
"UnsetParentIssue": "상위 이슈 설정 해제",
"ForbidCreateProjectPermission": "프로젝트 생성 금지",
"ForbidCreateProjectPermissionDescription": "사용자의 새 프로젝트 생성을 금지",
- "AllowCreatingIssues": "이슈 생성 허용"
+ "AllowCreatingIssues": "이슈 생성 허용",
+ "Day": "Day",
+ "Week": "Week",
+ "Month": "Month",
+ "Quarter": "Quarter",
+ "Gantt": "Gantt",
+ "GanttShowIssueCode": "Show issue code",
+ "GanttShowTitle": "Show title",
+ "GanttShowStatus": "Show status",
+ "GanttToday": "Today",
+ "GanttJumpToStart": "Jump to start",
+ "GanttJumpToEnd": "Jump to end",
+ "GanttJumpToDate": "Jump to date",
+ "GanttPreviousPeriod": "Previous period",
+ "GanttNextPeriod": "Next period",
+ "GanttScrollLeftToBar": "Scroll left to bar",
+ "GanttScrollRightToBar": "Scroll right to bar",
+ "GanttExpand": "Expand",
+ "GanttCollapse": "Collapse"
},
"status": {}
}
diff --git a/plugins/tracker-assets/lang/pt-br.json b/plugins/tracker-assets/lang/pt-br.json
index 9d8ec0214e2..23b0d3cd1aa 100644
--- a/plugins/tracker-assets/lang/pt-br.json
+++ b/plugins/tracker-assets/lang/pt-br.json
@@ -276,7 +276,26 @@
"UnsetParentIssue": "Desmarcar problema pai",
"ForbidCreateProjectPermission": "Proibir criação de projeto",
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos",
- "AllowCreatingIssues": "Permitir criar problemas"
+ "AllowCreatingIssues": "Permitir criar problemas",
+ "Day": "Day",
+ "Week": "Week",
+ "Month": "Month",
+ "Quarter": "Quarter",
+ "Gantt": "Gantt",
+ "GanttShowIssueCode": "Show issue code",
+ "GanttShowTitle": "Show title",
+ "GanttShowStatus": "Show status",
+ "GanttToday": "Today",
+ "GanttJumpToStart": "Jump to start",
+ "GanttJumpToEnd": "Jump to end",
+ "GanttJumpToDate": "Jump to date",
+ "GanttPreviousPeriod": "Previous period",
+ "GanttNextPeriod": "Next period",
+ "GanttScrollLeftToBar": "Scroll left to bar",
+ "GanttScrollRightToBar": "Scroll right to bar",
+ "GanttExpand": "Expand",
+ "GanttCollapse": "Collapse"
+
},
"status": {}
}
diff --git a/plugins/tracker-assets/lang/pt.json b/plugins/tracker-assets/lang/pt.json
index 82a640e2c59..9dd63bd05c6 100644
--- a/plugins/tracker-assets/lang/pt.json
+++ b/plugins/tracker-assets/lang/pt.json
@@ -276,7 +276,26 @@
"UnsetParentIssue": "Desmarcar problema pai",
"ForbidCreateProjectPermission": "Proibir criação de projeto",
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos",
- "AllowCreatingIssues": "Permitir criar problemas"
+ "AllowCreatingIssues": "Permitir criar problemas",
+ "Day": "Day",
+ "Week": "Week",
+ "Month": "Month",
+ "Quarter": "Quarter",
+ "Gantt": "Gantt",
+ "GanttShowIssueCode": "Show issue code",
+ "GanttShowTitle": "Show title",
+ "GanttShowStatus": "Show status",
+ "GanttToday": "Today",
+ "GanttJumpToStart": "Jump to start",
+ "GanttJumpToEnd": "Jump to end",
+ "GanttJumpToDate": "Jump to date",
+ "GanttPreviousPeriod": "Previous period",
+ "GanttNextPeriod": "Next period",
+ "GanttScrollLeftToBar": "Scroll left to bar",
+ "GanttScrollRightToBar": "Scroll right to bar",
+ "GanttExpand": "Expand",
+ "GanttCollapse": "Collapse"
+
},
"status": {}
}
diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json
index 7ccd4cb5ccd..9d6780bb05f 100644
--- a/plugins/tracker-assets/lang/ru.json
+++ b/plugins/tracker-assets/lang/ru.json
@@ -293,7 +293,26 @@
"UnsetParentIssue": "Снять родительскую задачу",
"ForbidCreateProjectPermission": "Запретить создание проекта",
"ForbidCreateProjectPermissionDescription": "Запрещает пользователям создавать новые проекты",
- "AllowCreatingIssues": "Разрешить создание задач"
+ "AllowCreatingIssues": "Разрешить создание задач",
+ "Day": "День",
+ "Week": "Неделя",
+ "Month": "Месяц",
+ "Quarter": "Квартал",
+ "Gantt": "Гантт",
+ "GanttShowIssueCode": "Показать код задачи",
+ "GanttShowTitle": "Показать заголовок",
+ "GanttShowStatus": "Показать статус",
+ "GanttToday": "Сегодня",
+ "GanttJumpToStart": "Перейти к началу",
+ "GanttJumpToEnd": "Перейти к концу",
+ "GanttJumpToDate": "Перейти к дате",
+ "GanttPreviousPeriod": "Предыдущий период",
+ "GanttNextPeriod": "Следующий период",
+ "GanttScrollLeftToBar": "Прокрутить влево к полосе",
+ "GanttScrollRightToBar": "Прокрутить вправо к полосе",
+ "GanttExpand": "Развернуть",
+ "GanttCollapse": "Свернуть"
+
},
"status": {}
}
diff --git a/plugins/tracker-assets/lang/tr.json b/plugins/tracker-assets/lang/tr.json
index ec118014359..86ce5441d07 100644
--- a/plugins/tracker-assets/lang/tr.json
+++ b/plugins/tracker-assets/lang/tr.json
@@ -274,7 +274,26 @@
"IssueStatus": "Durum",
"Extensions": "Uzantılar",
"UnsetParentIssue": "Üst sorunu kaldır",
- "AllowCreatingIssues": "Sorun oluşturmaya izin ver"
+ "AllowCreatingIssues": "Sorun oluşturmaya izin ver",
+ "Day": "Day",
+ "Week": "Week",
+ "Month": "Month",
+ "Quarter": "Quarter",
+ "Gantt": "Gantt",
+ "GanttShowIssueCode": "Show issue code",
+ "GanttShowTitle": "Show title",
+ "GanttShowStatus": "Show status",
+ "GanttToday": "Today",
+ "GanttJumpToStart": "Jump to start",
+ "GanttJumpToEnd": "Jump to end",
+ "GanttJumpToDate": "Jump to date",
+ "GanttPreviousPeriod": "Previous period",
+ "GanttNextPeriod": "Next period",
+ "GanttScrollLeftToBar": "Scroll left to bar",
+ "GanttScrollRightToBar": "Scroll right to bar",
+ "GanttExpand": "Expand",
+ "GanttCollapse": "Collapse"
+
},
"status": {}
}
diff --git a/plugins/tracker-assets/lang/zh.json b/plugins/tracker-assets/lang/zh.json
index a424d856cac..8bdb48e80bb 100644
--- a/plugins/tracker-assets/lang/zh.json
+++ b/plugins/tracker-assets/lang/zh.json
@@ -293,7 +293,26 @@
"UnsetParentIssue": "取消父问题",
"ForbidCreateProjectPermission": "禁止创建项目",
"ForbidCreateProjectPermissionDescription": "禁止用户创建新项目",
- "AllowCreatingIssues": "允许创建问题"
+ "AllowCreatingIssues": "允许创建问题",
+ "Day": "Day",
+ "Week": "Week",
+ "Month": "Month",
+ "Quarter": "Quarter",
+ "Gantt": "Gantt",
+ "GanttShowIssueCode": "Show issue code",
+ "GanttShowTitle": "Show title",
+ "GanttShowStatus": "Show status",
+ "GanttToday": "Today",
+ "GanttJumpToStart": "Jump to start",
+ "GanttJumpToEnd": "Jump to end",
+ "GanttJumpToDate": "Jump to date",
+ "GanttPreviousPeriod": "Previous period",
+ "GanttNextPeriod": "Next period",
+ "GanttScrollLeftToBar": "Scroll left to bar",
+ "GanttScrollRightToBar": "Scroll right to bar",
+ "GanttExpand": "Expand",
+ "GanttCollapse": "Collapse"
+
},
"status": {}
}
diff --git a/plugins/tracker-assets/src/index.ts b/plugins/tracker-assets/src/index.ts
index 07aaf1badab..4c64fa48f9e 100644
--- a/plugins/tracker-assets/src/index.ts
+++ b/plugins/tracker-assets/src/index.ts
@@ -64,5 +64,6 @@ loadMetadata(tracker.icon, {
CopyBranch: `${icons}#copyBranch`,
Duplicate: `${icons}#duplicate`,
TimeReport: `${icons}#timeReport`,
- Estimation: `${icons}#estimation`
+ Estimation: `${icons}#estimation`,
+ Gantt: `${icons}#timeline`
})
diff --git a/plugins/tracker-resources/package.json b/plugins/tracker-resources/package.json
index 7afa566df43..7ae0b97d0a3 100644
--- a/plugins/tracker-resources/package.json
+++ b/plugins/tracker-resources/package.json
@@ -8,12 +8,14 @@
"build": "compile ui",
"build:docs": "api-extractor run --local",
"svelte-check": "do-svelte-check",
+ "test": "jest --passWithNoTests --silent",
"_phase:svelte-check": "do-svelte-check",
"format": "format src",
"build:watch": "compile ui",
"_phase:build": "compile ui",
"_phase:format": "format src",
- "_phase:validate": "compile validate"
+ "_phase:validate": "compile validate",
+ "_phase:test": "jest --passWithNoTests --silent"
},
"devDependencies": {
"svelte-loader": "^3.2.0",
diff --git a/plugins/tracker-resources/src/components/gantt/.gitignore b/plugins/tracker-resources/src/components/gantt/.gitignore
new file mode 100644
index 00000000000..176a11c6ff4
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/.gitignore
@@ -0,0 +1,3 @@
+# Override root-level lib/ ignore — this lib/ is source code, not build output.
+!lib/
+!lib/**
diff --git a/plugins/tracker-resources/src/components/gantt/GanttBar.svelte b/plugins/tracker-resources/src/components/gantt/GanttBar.svelte
new file mode 100644
index 00000000000..ebd9e0dd3cd
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/GanttBar.svelte
@@ -0,0 +1,138 @@
+
+
+
+{#if visible}
+ {@const barY = row.y + 6}
+ {@const barH = row.height - 12}
+ {#if isSummary}
+
+
+
+
+ {#if barLabel !== ''}
+ {barLabel}
+ {/if}
+
{tooltipText}
+ {:else}
+
+ {#if barLabel !== ''}
+ {barLabel}
+ {/if}
+ {tooltipText}
+ {/if}
+{/if}
+
+
diff --git a/plugins/tracker-resources/src/components/gantt/GanttCanvas.svelte b/plugins/tracker-resources/src/components/gantt/GanttCanvas.svelte
new file mode 100644
index 00000000000..9ce386d2765
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/GanttCanvas.svelte
@@ -0,0 +1,191 @@
+
+
+
+
+
+
diff --git a/plugins/tracker-resources/src/components/gantt/GanttHeader.svelte b/plugins/tracker-resources/src/components/gantt/GanttHeader.svelte
new file mode 100644
index 00000000000..718c97969d0
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/GanttHeader.svelte
@@ -0,0 +1,55 @@
+
+
+
+
+
+
diff --git a/plugins/tracker-resources/src/components/gantt/GanttSidebar.svelte b/plugins/tracker-resources/src/components/gantt/GanttSidebar.svelte
new file mode 100644
index 00000000000..2310ac62b7d
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/GanttSidebar.svelte
@@ -0,0 +1,320 @@
+
+
+
+
+
+
diff --git a/plugins/tracker-resources/src/components/gantt/GanttTodayMarker.svelte b/plugins/tracker-resources/src/components/gantt/GanttTodayMarker.svelte
new file mode 100644
index 00000000000..f54a228822b
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/GanttTodayMarker.svelte
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+ {dateLabel}
+
+
+
diff --git a/plugins/tracker-resources/src/components/gantt/GanttView.svelte b/plugins/tracker-resources/src/components/gantt/GanttView.svelte
new file mode 100644
index 00000000000..1db22db58c3
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/GanttView.svelte
@@ -0,0 +1,1174 @@
+
+
+
+
+ {#if loading}
+
+ {:else}
+
+
+
+
+
+
+ {#if vHasOverflow}
+
+
+ {/if}
+
+ {#if hHasOverflow}
+
+
+ {/if}
+ {#if tooltipState.visible && tooltipState.row !== null}
+ {@const row = tooltipState.row}
+ {@const issue = row.issue}
+ {@const ms = row.milestone}
+
+ {/if}
+ {/if}
+
+
+
diff --git a/plugins/tracker-resources/src/components/gantt/StatusBadge.svelte b/plugins/tracker-resources/src/components/gantt/StatusBadge.svelte
new file mode 100644
index 00000000000..fcb3c0debae
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/StatusBadge.svelte
@@ -0,0 +1,44 @@
+
+
+
+
+
+
diff --git a/plugins/tracker-resources/src/components/gantt/lib/__tests__/layout.test.ts b/plugins/tracker-resources/src/components/gantt/lib/__tests__/layout.test.ts
new file mode 100644
index 00000000000..b6e7b817632
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/lib/__tests__/layout.test.ts
@@ -0,0 +1,195 @@
+//
+// Copyright © 2026 Hardcore Engineering Inc.
+// SPDX-License-Identifier: EPL-2.0
+//
+
+import type { Issue } from '@hcengineering/tracker'
+import { buildLayout, filterVisibleRows } from '../layout'
+import { type LayoutRow, type MilestoneMarker } from '../types'
+
+function fakeIssue (id: string, parentId?: string, hasChildren = false, milestone?: string): Issue {
+ return {
+ _id: id as any,
+ _class: 'tracker:class:Issue' as any,
+ title: `Issue ${id}`,
+ space: 'project-1' as any,
+ component: null,
+ milestone: milestone ?? null,
+ startDate: null,
+ dueDate: null,
+ parents: parentId !== undefined ? [{ parentId: parentId as any }] : [],
+ childInfo: hasChildren ? [{ childId: 'fake-child' as any, count: 0, category: '' }] : [],
+ estimation: 0,
+ remainingTime: 0,
+ reportedTime: 0,
+ reports: 0,
+ subIssues: 0,
+ priority: 0,
+ status: '' as any,
+ attachedTo: 'tracker:ids:NoParent' as any
+ } as unknown as Issue
+}
+
+function fakeMilestone (id: string, label = `MS ${id}`): MilestoneMarker {
+ return { _id: id as any, label, startDate: null, targetDate: 1_700_000_000_000 }
+}
+
+const ROW_H = 28
+
+describe('buildLayout (no grouping)', () => {
+ it('flattens a flat list of root issues', () => {
+ const issues = [fakeIssue('a'), fakeIssue('b')]
+ const rows = buildLayout(issues, [], 'none', ROW_H)
+ expect(rows).toHaveLength(2)
+ expect(rows[0].issue?._id).toBe('a')
+ expect(rows[1].issue?._id).toBe('b')
+ expect(rows[0].depth).toBe(0)
+ expect(rows[0].y).toBe(0)
+ expect(rows[1].y).toBe(ROW_H)
+ })
+
+ it('places children below parent with depth+1', () => {
+ const a = fakeIssue('a', undefined, true)
+ const child = fakeIssue('a.1', 'a')
+ const rows = buildLayout([a, child], [], 'none', ROW_H)
+ expect(rows.find((r) => r.issue?._id === 'a.1')?.depth).toBe(1)
+ })
+
+ it('marks parent issues as summary rows', () => {
+ const a = fakeIssue('a', undefined, true)
+ const child = fakeIssue('a.1', 'a')
+ const rows = buildLayout([a, child], [], 'none', ROW_H)
+ const parentRow = rows.find((r) => r.issue?._id === 'a')
+ expect(parentRow?.isSummary).toBe(true)
+ expect(rows.find((r) => r.issue?._id === 'a.1')?.isSummary).toBe(false)
+ })
+
+ it('row Y coordinates are sequential multiples of rowHeight', () => {
+ const issues = [fakeIssue('a'), fakeIssue('b'), fakeIssue('c')]
+ const rows = buildLayout(issues, [], 'none', ROW_H)
+ expect(rows.map((r) => r.y)).toEqual([0, ROW_H, 2 * ROW_H])
+ })
+
+ it('emits orphan children as roots when their parent is not in the input set', () => {
+ const a = fakeIssue('a', 'p')
+ const b = fakeIssue('b', 'p')
+ const rows = buildLayout([a, b], [], 'none', ROW_H)
+ expect(rows.map((r) => r.issue?._id)).toEqual(['a', 'b'])
+ expect(rows.every((r) => r.depth === 0)).toBe(true)
+ })
+
+ it('keeps real parent/child nesting when parent IS in the input set', () => {
+ const parent = fakeIssue('p', undefined, true)
+ const childA = fakeIssue('a', 'p')
+ const childB = fakeIssue('b', 'p')
+ const rows = buildLayout([parent, childA, childB], [], 'none', ROW_H)
+ expect(rows.map((r) => r.issue?._id)).toEqual(['p', 'a', 'b'])
+ expect(rows[0].depth).toBe(0)
+ expect(rows[1].depth).toBe(1)
+ expect(rows[2].depth).toBe(1)
+ })
+})
+
+describe('buildLayout — milestones', () => {
+ it('emits milestone parent rows above their issues', () => {
+ const ms = fakeMilestone('m1')
+ const i1 = fakeIssue('a', undefined, false, 'm1')
+ const i2 = fakeIssue('b', undefined, false, 'm1')
+ const rows = buildLayout([i1, i2], [ms], 'none', ROW_H)
+ expect(rows.map((r) => r.id)).toEqual(['milestone:m1', 'issue:a', 'issue:b'])
+ expect(rows[0].kind).toBe('milestone')
+ expect(rows[0].milestone?.label).toBe('MS m1')
+ expect(rows[1].depth).toBe(1)
+ expect(rows[2].depth).toBe(1)
+ })
+
+ it('places issues without a known milestone as top-level roots', () => {
+ const i1 = fakeIssue('a', undefined, false, 'unknown-ms')
+ const i2 = fakeIssue('b')
+ const rows = buildLayout([i1, i2], [], 'none', ROW_H)
+ expect(rows.map((r) => r.id)).toEqual(['issue:a', 'issue:b'])
+ expect(rows.every((r) => r.depth === 0)).toBe(true)
+ })
+
+ it('mixes milestone groups with bare issues (milestones first)', () => {
+ const ms = fakeMilestone('m1')
+ const inGroup = fakeIssue('a', undefined, false, 'm1')
+ const ungrouped = fakeIssue('b')
+ const rows = buildLayout([inGroup, ungrouped], [ms], 'none', ROW_H)
+ expect(rows.map((r) => r.id)).toEqual(['milestone:m1', 'issue:a', 'issue:b'])
+ })
+})
+
+describe('buildLayout — collapse', () => {
+ it('hides children of a collapsed milestone row', () => {
+ const ms = fakeMilestone('m1')
+ const i1 = fakeIssue('a', undefined, false, 'm1')
+ const i2 = fakeIssue('b', undefined, false, 'm1')
+ const rows = buildLayout([i1, i2], [ms], 'none', {
+ rowHeight: ROW_H,
+ collapsedIds: new Set(['milestone:m1'])
+ })
+ expect(rows.map((r) => r.id)).toEqual(['milestone:m1'])
+ expect(rows[0].collapsed).toBe(true)
+ })
+
+ it('hides sub-issues of a collapsed parent issue', () => {
+ const parent = fakeIssue('p', undefined, true)
+ const child = fakeIssue('a', 'p')
+ const rows = buildLayout([parent, child], [], 'none', {
+ rowHeight: ROW_H,
+ collapsedIds: new Set(['issue:p'])
+ })
+ expect(rows.map((r) => r.id)).toEqual(['issue:p'])
+ expect(rows[0].collapsed).toBe(true)
+ expect(rows[0].collapsible).toBe(true)
+ })
+
+ it('expanded parents render their children', () => {
+ const parent = fakeIssue('p', undefined, true)
+ const child = fakeIssue('a', 'p')
+ const rows = buildLayout([parent, child], [], 'none', {
+ rowHeight: ROW_H,
+ collapsedIds: new Set()
+ })
+ expect(rows.map((r) => r.id)).toEqual(['issue:p', 'issue:a'])
+ expect(rows[0].collapsed).toBe(false)
+ })
+})
+
+describe('filterVisibleRows', () => {
+ function row (y: number): LayoutRow {
+ return {
+ kind: 'issue',
+ id: `r-${y}`,
+ y,
+ height: ROW_H,
+ depth: 0,
+ visible: true,
+ issue: null,
+ milestone: null,
+ component: null,
+ isSummary: false,
+ collapsible: false,
+ collapsed: false
+ }
+ }
+
+ it('returns only rows whose Y range intersects the viewport (overscan=0)', () => {
+ const all: LayoutRow[] = [row(0), row(100), row(5000)]
+ const visible = filterVisibleRows(all, 80, 60, 0)
+ expect(visible.map((r) => r.y)).toEqual([100])
+ })
+
+ it('default overscan brings adjacent rows into the visible set', () => {
+ const all: LayoutRow[] = [row(0), row(100), row(5000)]
+ const visible = filterVisibleRows(all, 80, 60)
+ expect(visible.map((r) => r.y).sort((a, b) => a - b)).toEqual([0, 100])
+ })
+
+ it('honours an explicit overscan', () => {
+ const all: LayoutRow[] = [row(0), row(1000)]
+ const visible = filterVisibleRows(all, 950, 50, 200)
+ expect(visible.map((r) => r.y)).toEqual([1000])
+ })
+})
diff --git a/plugins/tracker-resources/src/components/gantt/lib/__tests__/time-scale.test.ts b/plugins/tracker-resources/src/components/gantt/lib/__tests__/time-scale.test.ts
new file mode 100644
index 00000000000..e58c9b9736d
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/lib/__tests__/time-scale.test.ts
@@ -0,0 +1,78 @@
+//
+// Copyright © 2026 Hardcore Engineering Inc.
+// SPDX-License-Identifier: EPL-2.0
+//
+
+import { createTimeScale, snapToUtcMidnight } from '../time-scale'
+
+const DAY_MS = 86_400_000
+
+describe('snapToUtcMidnight', () => {
+ it('returns 0 unchanged', () => {
+ expect(snapToUtcMidnight(0)).toBe(0)
+ })
+
+ it('rounds down to UTC midnight', () => {
+ const t = Date.UTC(2026, 4, 15, 17, 30, 45) // 2026-05-15 17:30:45 UTC
+ expect(snapToUtcMidnight(t)).toBe(Date.UTC(2026, 4, 15))
+ })
+
+ it('is idempotent', () => {
+ const t = Date.UTC(2026, 0, 1)
+ expect(snapToUtcMidnight(snapToUtcMidnight(t))).toBe(t)
+ })
+})
+
+describe('createTimeScale', () => {
+ const origin = Date.UTC(2026, 0, 1) // 2026-01-01 UTC
+
+ it('week zoom: pxPerDay = 14', () => {
+ const ts = createTimeScale('week', origin)
+ expect(ts.pxPerDay).toBe(14)
+ })
+
+ it('day/month/quarter zoom values match preset', () => {
+ expect(createTimeScale('day', origin).pxPerDay).toBe(32)
+ expect(createTimeScale('month', origin).pxPerDay).toBe(4)
+ expect(createTimeScale('quarter', origin).pxPerDay).toBe(1.5)
+ })
+
+ it('toX(origin) === 0', () => {
+ const ts = createTimeScale('week', origin)
+ expect(ts.toX(origin)).toBe(0)
+ })
+
+ it('toX(origin + 7d) === 7 * pxPerDay', () => {
+ const ts = createTimeScale('week', origin)
+ expect(ts.toX(origin + 7 * DAY_MS)).toBe(7 * 14)
+ })
+
+ it('fromX is inverse of toX (snapped)', () => {
+ const ts = createTimeScale('week', origin)
+ const t = origin + 5 * DAY_MS
+ expect(ts.fromX(ts.toX(t))).toBe(t)
+ })
+
+ it('week zoom emits weekly ticks aligned to Monday', () => {
+ const ts = createTimeScale('week', origin)
+ const ticks = ts.ticks([origin, origin + 30 * DAY_MS])
+ expect(ticks.length).toBeGreaterThanOrEqual(4)
+ expect(ticks.length).toBeLessThanOrEqual(6)
+ // First tick is the first Monday on or after origin
+ expect(ticks[0].date).toBeGreaterThanOrEqual(origin)
+ expect(ticks[0].date).toBeLessThan(origin + 7 * DAY_MS)
+ // Every tick is on a Monday.
+ for (const t of ticks) {
+ expect(new Date(t.date).getUTCDay()).toBe(1)
+ }
+ expect(ticks.every((t) => Number.isInteger(t.date))).toBe(true)
+ })
+
+ it('all tick dates are UTC midnights', () => {
+ const ts = createTimeScale('week', origin)
+ const ticks = ts.ticks([origin, origin + 30 * DAY_MS])
+ for (const t of ticks) {
+ expect(snapToUtcMidnight(t.date)).toBe(t.date)
+ }
+ })
+})
diff --git a/plugins/tracker-resources/src/components/gantt/lib/layout.ts b/plugins/tracker-resources/src/components/gantt/lib/layout.ts
new file mode 100644
index 00000000000..132d3fbe8ee
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/lib/layout.ts
@@ -0,0 +1,154 @@
+//
+// Copyright © 2026 Hardcore Engineering Inc.
+// SPDX-License-Identifier: EPL-2.0
+//
+
+import { type Issue } from '@hcengineering/tracker'
+import { type LayoutRow, type MilestoneMarker } from './types'
+
+export type GroupBy = 'none' | 'component' | 'milestone'
+
+const DEFAULT_OVERSCAN_PX = 240
+
+/** Stable id used for keyed iteration AND collapse-state lookup. */
+export function rowId (row: LayoutRow): string {
+ return row.id
+}
+
+export interface BuildLayoutOptions {
+ rowHeight: number
+ /** Set of row ids that are currently collapsed (children hidden). */
+ collapsedIds?: Set
+}
+
+/**
+ * Build the flattened row layout.
+ *
+ * Hierarchy from top to bottom:
+ * Milestone-row (if any issues belong to that milestone)
+ * └── Issue-root
+ * └── Issue-child (sub-issue)
+ *
+ * Issues without a milestone are emitted as roots after the milestone groups.
+ * Issues whose parent is filtered out are promoted to roots so they don't
+ * silently disappear from the Gantt.
+ */
+export function buildLayout (
+ issues: Issue[],
+ milestones: MilestoneMarker[],
+ _group: GroupBy,
+ rowHeightOrOpts: number | BuildLayoutOptions
+): LayoutRow[] {
+ const opts: BuildLayoutOptions =
+ typeof rowHeightOrOpts === 'number' ? { rowHeight: rowHeightOrOpts } : rowHeightOrOpts
+ const rowHeight = opts.rowHeight
+ const collapsedIds = opts.collapsedIds ?? new Set()
+
+ // 1) Build issue parent/child map, dropping orphan parent refs.
+ const visibleIssueIds = new Set(issues.map((i) => i._id as unknown as string))
+ const issueChildrenOf = new Map()
+ const issueRoots: Issue[] = []
+ for (const i of issues) {
+ const parentId = i.parents?.[0]?.parentId as unknown as string | undefined
+ if (parentId != null && visibleIssueIds.has(parentId)) {
+ const list = issueChildrenOf.get(parentId) ?? []
+ list.push(i)
+ issueChildrenOf.set(parentId, list)
+ } else {
+ issueRoots.push(i)
+ }
+ }
+
+ // 2) Group root-issues by milestone.
+ const milestoneById = new Map()
+ for (const m of milestones) {
+ milestoneById.set(m._id as unknown as string, m)
+ }
+ const issuesByMilestone = new Map()
+ const issuesWithoutMilestone: Issue[] = []
+ for (const root of issueRoots) {
+ const ms = (root as unknown as { milestone?: string | null }).milestone
+ if (ms != null && milestoneById.has(ms)) {
+ const list = issuesByMilestone.get(ms) ?? []
+ list.push(root)
+ issuesByMilestone.set(ms, list)
+ } else {
+ issuesWithoutMilestone.push(root)
+ }
+ }
+
+ const rows: LayoutRow[] = []
+ let y = 0
+
+ function emitIssue (issue: Issue, depth: number): void {
+ const issueId = issue._id as unknown as string
+ const kids = issueChildrenOf.get(issueId) ?? []
+ const id = `issue:${issueId}`
+ const collapsible = kids.length > 0
+ const collapsed = collapsedIds.has(id)
+ rows.push({
+ kind: 'issue',
+ id,
+ y,
+ height: rowHeight,
+ depth,
+ visible: true,
+ issue,
+ milestone: null,
+ component: null,
+ isSummary: kids.length > 0,
+ collapsible,
+ collapsed
+ })
+ y += rowHeight
+ if (!collapsed) {
+ for (const c of kids) emitIssue(c, depth + 1)
+ }
+ }
+
+ // 3a) Milestone groups first.
+ for (const [msId, msIssues] of issuesByMilestone) {
+ const ms = milestoneById.get(msId)
+ if (ms === undefined) continue
+ const id = `milestone:${msId}`
+ const collapsed = collapsedIds.has(id)
+ rows.push({
+ kind: 'milestone',
+ id,
+ y,
+ height: rowHeight,
+ depth: 0,
+ visible: true,
+ issue: null,
+ milestone: ms,
+ component: null,
+ isSummary: true,
+ collapsible: true,
+ collapsed
+ })
+ y += rowHeight
+ if (!collapsed) {
+ for (const i of msIssues) emitIssue(i, 1)
+ }
+ }
+
+ // 3b) Issues without milestone.
+ for (const r of issuesWithoutMilestone) emitIssue(r, 0)
+
+ return rows
+}
+
+/**
+ * Return the subset of rows whose [y, y+height] intersects
+ * [viewportTop - overscan, viewportTop + viewportHeight + overscan].
+ */
+export function filterVisibleRows (
+ rows: LayoutRow[],
+ viewportTop: number,
+ viewportHeight: number,
+ overscan: number = DEFAULT_OVERSCAN_PX
+): LayoutRow[] {
+ const min = viewportTop - overscan
+ const max = viewportTop + viewportHeight + overscan
+ return rows.filter((r) => r.y + r.height >= min && r.y <= max)
+}
diff --git a/plugins/tracker-resources/src/components/gantt/lib/time-scale.ts b/plugins/tracker-resources/src/components/gantt/lib/time-scale.ts
new file mode 100644
index 00000000000..4ed9458412c
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/lib/time-scale.ts
@@ -0,0 +1,137 @@
+//
+// Copyright © 2026 Hardcore Engineering Inc.
+// SPDX-License-Identifier: EPL-2.0
+//
+
+import { type Tick, type ZoomLevel } from './types'
+
+const DAY_MS = 86_400_000
+
+const PX_PER_DAY: Record = {
+ day: 32,
+ week: 14,
+ month: 4,
+ quarter: 1.5
+}
+
+/** Snap any Timestamp (ms) to the start of its UTC day. */
+export function snapToUtcMidnight (t: number): number {
+ const d = new Date(t)
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
+}
+
+export interface TimeScale {
+ /** Pixel width of one calendar day at the current zoom. */
+ pxPerDay: number
+ /** Convert a Timestamp to its X coordinate (relative to origin). */
+ toX: (t: number) => number
+ /** Convert an X coordinate back to a snapped Timestamp. */
+ fromX: (x: number) => number
+ /** Generate header ticks across [from, to]. */
+ ticks: (range: [number, number]) => Tick[]
+}
+
+export function createTimeScale (zoom: ZoomLevel, origin: number): TimeScale {
+ const pxPerDay = PX_PER_DAY[zoom]
+ const originSnapped = snapToUtcMidnight(origin)
+
+ const toX = (t: number): number => ((t - originSnapped) / DAY_MS) * pxPerDay
+ const fromX = (x: number): number => snapToUtcMidnight(originSnapped + (x / pxPerDay) * DAY_MS)
+
+ const ticks = (range: [number, number]): Tick[] => {
+ const [from, to] = range
+ const fromDay = snapToUtcMidnight(from)
+ const result: Tick[] = []
+ let cursor = fromDay
+
+ switch (zoom) {
+ case 'day': {
+ while (cursor <= to) {
+ const d = new Date(cursor)
+ const isMonday = d.getUTCDay() === 1
+ result.push({
+ date: cursor,
+ label: d.getUTCDate().toString(),
+ level: isMonday ? 'major' : 'minor'
+ })
+ cursor += DAY_MS
+ }
+ break
+ }
+ case 'week': {
+ const d = new Date(cursor)
+ const dow = d.getUTCDay() // 0=Sun
+ const offsetToMonday = (1 - dow + 7) % 7
+ cursor += offsetToMonday * DAY_MS
+ while (cursor <= to) {
+ const c = new Date(cursor)
+ const isFirstWeekOfMonth = c.getUTCDate() <= 7
+ result.push({
+ date: cursor,
+ label: `W${isoWeekNumber(c)}`,
+ level: isFirstWeekOfMonth ? 'major' : 'minor'
+ })
+ cursor += 7 * DAY_MS
+ }
+ break
+ }
+ case 'month': {
+ const start = new Date(cursor)
+ let y = start.getUTCFullYear()
+ let m = start.getUTCMonth()
+ cursor = Date.UTC(y, m, 1)
+ while (cursor <= to) {
+ const c = new Date(cursor)
+ result.push({
+ date: cursor,
+ label: c.toLocaleString(undefined, { month: 'short', timeZone: 'UTC' }),
+ level: c.getUTCMonth() === 0 ? 'major' : 'minor'
+ })
+ m += 1
+ if (m > 11) {
+ m = 0
+ y += 1
+ }
+ cursor = Date.UTC(y, m, 1)
+ }
+ break
+ }
+ case 'quarter': {
+ const start = new Date(cursor)
+ let y = start.getUTCFullYear()
+ let q = Math.floor(start.getUTCMonth() / 3)
+ cursor = Date.UTC(y, q * 3, 1)
+ while (cursor <= to) {
+ const c = new Date(cursor)
+ const qNum = Math.floor(c.getUTCMonth() / 3) + 1
+ result.push({
+ date: cursor,
+ label: `Q${qNum} ${c.getUTCFullYear()}`,
+ level: qNum === 1 ? 'major' : 'minor'
+ })
+ q += 1
+ if (q > 3) {
+ q = 0
+ y += 1
+ }
+ cursor = Date.UTC(y, q * 3, 1)
+ }
+ break
+ }
+ }
+
+ return result
+ }
+
+ return { pxPerDay, toX, fromX, ticks }
+}
+
+/** ISO 8601 week number for a UTC date. */
+function isoWeekNumber (d: Date): number {
+ const target = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
+ const dayNum = (target.getUTCDay() + 6) % 7
+ target.setUTCDate(target.getUTCDate() - dayNum + 3)
+ const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4))
+ const diff = target.getTime() - firstThursday.getTime()
+ return 1 + Math.round(diff / (7 * DAY_MS))
+}
diff --git a/plugins/tracker-resources/src/components/gantt/lib/types.ts b/plugins/tracker-resources/src/components/gantt/lib/types.ts
new file mode 100644
index 00000000000..6010da117dc
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/lib/types.ts
@@ -0,0 +1,58 @@
+//
+// Copyright © 2026 Hardcore Engineering Inc.
+// SPDX-License-Identifier: EPL-2.0
+//
+
+import { type Ref } from '@hcengineering/core'
+import { type Issue, type Component as TrackerComponent, type Milestone } from '@hcengineering/tracker'
+
+/** Zoom presets — controls pxPerDay and header tick density. */
+export type ZoomLevel = 'day' | 'week' | 'month' | 'quarter'
+
+/** A single tick on the time-axis header (vertical gridline + label). */
+export interface Tick {
+ date: number // UTC ms
+ label: string // pre-formatted, locale-aware
+ level: 'major' | 'minor' // major ticks render thicker + with text label
+}
+
+/** A row in the flattened layout. May be an issue, milestone, or swimlane header. */
+export interface LayoutRow {
+ kind: 'issue' | 'milestone' | 'component-swimlane'
+ /** Stable key for keyed each-blocks and the collapsed-set. */
+ id: string
+ /** Y-coord top-edge of the row in canvas pixels. */
+ y: number
+ /** Row height in pixels. */
+ height: number
+ /** Tree depth — 0 for top-level rows. */
+ depth: number
+ /** Whether this row is currently rendered (vs virtually skipped). */
+ visible: boolean
+ /** The issue this row represents — null for milestone/swimlane rows. */
+ issue: Issue | null
+ /** The milestone this row represents — null for issue/swimlane rows. */
+ milestone: MilestoneMarker | null
+ /** The component this swimlane represents — null otherwise. */
+ component: Ref | null
+ /** True iff this row has children (renders as summary "claw" bar). */
+ isSummary: boolean
+ /** True iff this row has children and the user can collapse/expand it. */
+ collapsible: boolean
+ /** True iff currently collapsed (children hidden). */
+ collapsed: boolean
+}
+
+/** Cached aggregate dates of a parent issue's children, for summary-bar rendering. */
+export interface SummaryRange {
+ startDate: number | null
+ dueDate: number | null
+}
+
+/** Compact view of a Milestone for the canvas overlay. */
+export interface MilestoneMarker {
+ _id: Ref
+ label: string
+ startDate: number | null
+ targetDate: number
+}
diff --git a/plugins/tracker-resources/src/components/issues/IssuesView.svelte b/plugins/tracker-resources/src/components/issues/IssuesView.svelte
index 72af1e78e9f..b65d4950b91 100644
--- a/plugins/tracker-resources/src/components/issues/IssuesView.svelte
+++ b/plugins/tracker-resources/src/components/issues/IssuesView.svelte
@@ -3,12 +3,16 @@
import { Asset, IntlString, translateCB } from '@hcengineering/platform'
import { ComponentExtensions } from '@hcengineering/presentation'
import { Issue, TrackerEvents } from '@hcengineering/tracker'
- import { IModeSelector, themeStore } from '@hcengineering/ui'
+ import { Button, IconAdd, IModeSelector, showPopup, themeStore } from '@hcengineering/ui'
import { ViewOptions, Viewlet } from '@hcengineering/view'
import { FilterBar, SpaceHeader, ViewletContentView, ViewletSettingButton } from '@hcengineering/view-resources'
import tracker from '../../plugin'
import CreateIssue from '../CreateIssue.svelte'
+ function newIssue (): void {
+ showPopup(CreateIssue, { space, shouldSaveDraft: true }, 'top')
+ }
+
export let space: Ref | undefined = undefined
export let query: DocumentQuery = {}
export let title: IntlString | undefined = undefined
@@ -65,6 +69,14 @@
extension={tracker.extensions.IssueListHeader}
props={{ size: 'small', kind: 'tertiary', space }}
/>
+
=> ({
EditComponent,
IssuesView,
KanbanView,
+ GanttView,
ProjectComponents,
IssuePreview,
RelationsPopup,
diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts
index eed088c59d5..6f12cce9473 100644
--- a/plugins/tracker-resources/src/plugin.ts
+++ b/plugins/tracker-resources/src/plugin.ts
@@ -36,6 +36,8 @@ export default mergeIds(trackerId, tracker, {
SubIssues: '' as Ref,
List: '' as Ref,
Kanban: '' as Ref,
+ Gantt: '' as Ref,
+ IssueGantt: '' as Ref,
MilestoneIssuesList: '' as Ref,
ComponentIssuesList: '' as Ref
},
@@ -307,7 +309,26 @@ export default mergeIds(trackerId, tracker, {
UnsetParent: '' as IntlString,
PreviousAssigned: '' as IntlString,
EditRelatedTargets: '' as IntlString,
- RelatedIssueTargetDescription: '' as IntlString
+ RelatedIssueTargetDescription: '' as IntlString,
+
+ Day: '' as IntlString,
+ Gantt: '' as IntlString,
+ Month: '' as IntlString,
+ Quarter: '' as IntlString,
+ Week: '' as IntlString,
+ GanttShowIssueCode: '' as IntlString,
+ GanttShowTitle: '' as IntlString,
+ GanttShowStatus: '' as IntlString,
+ GanttToday: '' as IntlString,
+ GanttJumpToStart: '' as IntlString,
+ GanttJumpToEnd: '' as IntlString,
+ GanttJumpToDate: '' as IntlString,
+ GanttPreviousPeriod: '' as IntlString,
+ GanttNextPeriod: '' as IntlString,
+ GanttScrollLeftToBar: '' as IntlString,
+ GanttScrollRightToBar: '' as IntlString,
+ GanttExpand: '' as IntlString,
+ GanttCollapse: '' as IntlString
},
component: {
NopeComponent: '' as AnyComponent,
@@ -347,6 +368,7 @@ export default mergeIds(trackerId, tracker, {
EditComponent: '' as AnyComponent,
IssuesView: '' as AnyComponent,
KanbanView: '' as AnyComponent,
+ GanttView: '' as AnyComponent,
ProjectComponents: '' as AnyComponent,
IssuePreview: '' as AnyComponent,
RelationsPopup: '' as AnyComponent,
diff --git a/plugins/tracker/src/index.ts b/plugins/tracker/src/index.ts
index ae4ac58806f..da06d52bd5c 100644
--- a/plugins/tracker/src/index.ts
+++ b/plugins/tracker/src/index.ts
@@ -496,6 +496,7 @@ const pluginState = plugin(trackerId, {
TimeReport: '' as Asset,
Estimation: '' as Asset,
+ Gantt: '' as Asset,
// Project icons
Home: '' as Asset,