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..9709b4faf63 100644
--- a/models/tracker/src/viewlets.ts
+++ b/models/tracker/src/viewlets.ts
@@ -212,6 +212,75 @@ export function issueConfig (
]
}
+export function ganttViewOptions (): ViewOptionsModel {
+ // PR 2 ships a minimal read-only Gantt. Group-by + Show-colors are
+ // intentionally NOT advertised — 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
+ },
+ {
+ // Default-on safety prompt: when set, dragging an issue's bar to a
+ // new date range shows a confirm dialog before writing the change.
+ // User feedback 2026-05-11: easy to misclick a bar while panning,
+ // and a one-click confirm prevents accidental schedule edits.
+ key: 'ganttConfirmMove',
+ type: 'toggle',
+ defaultValue: true,
+ actionTarget: 'display',
+ label: tracker.string.GanttConfirmMove
+ },
+ {
+ // Same idea but for left/right resize handles.
+ key: 'ganttConfirmResize',
+ type: 'toggle',
+ defaultValue: true,
+ actionTarget: 'display',
+ label: tracker.string.GanttConfirmResize
+ }
+ ]
+ }
+}
+
+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 +293,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 +581,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..1f6eb8ce00c 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 Aufgaben erlauben",
+ "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/en.json b/plugins/tracker-assets/lang/en.json
index 3d856a20031..4657194a3b5 100644
--- a/plugins/tracker-assets/lang/en.json
+++ b/plugins/tracker-assets/lang/en.json
@@ -293,7 +293,45 @@
"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",
+ "GanttDragConflict": "Issue was changed by someone else, please retry",
+ "GanttDragFailed": "Failed to apply Gantt edit",
+ "GanttDragNoPermission": "You don't have permission to edit this issue",
+ "GanttDragValidation": "Date is not valid: {reason}",
+ "GanttResizingTooltip": "{from} days → {to} days ({delta} d)",
+ "SetStartDate": "Set start date…",
+ "Hierarchy": "Hierarchy",
+ "LinkExistingSubIssue": "Link existing as sub-issue…",
+ "SetParentIssueLabel": "Set parent issue…",
+ "GanttDragToSchedule": "Drag to schedule",
+ "GanttDurationTooltip": "Duration: {days} d",
+ "GanttConfirmMove": "Confirm before moving an issue",
+ "GanttConfirmResize": "Confirm before resizing an issue",
+ "GanttConfirmMoveTitle": "Move issue?",
+ "GanttConfirmResizeTitle": "Change issue dates?",
+ "GanttConfirmMoveBody": "Move {title} to {start} – {due}?",
+ "GanttConfirmResizeBody": "Change {title} to {start} – {due}?",
+ "GanttConfirmApply": "Apply",
+ "GanttAriaResizeStart": "Resize start date",
+ "GanttAriaResizeEnd": "Resize due date"
},
"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..381c9898add 100644
--- a/plugins/tracker-assets/lang/ru.json
+++ b/plugins/tracker-assets/lang/ru.json
@@ -9,9 +9,9 @@
"Open": "Открыть",
"Members": "Участники",
"Inbox": "Входящие",
+ "MyIssues": "Мои задачи",
"ViewIssue": "Открыть задачу",
"IssueCreated": "Задача создана",
- "MyIssues": "Мои задачи",
"Issues": "Задачи",
"Views": "Отображения",
"Active": "Активные",
@@ -54,8 +54,8 @@
"NewIssuePlaceholder": "Новая",
"ResumeDraft": "Восстановить черновик",
"SaveIssue": "Создать задачу",
- "SetPriority": "Установить приоритет\u2026",
- "SetStatus": "Установить статус\u2026",
+ "SetPriority": "Установить приоритет…",
+ "SetStatus": "Установить статус…",
"SelectIssue": "Выбрать задачу",
"Priority": "Приоритет",
"NoPriority": "Нет приоритета",
@@ -67,13 +67,11 @@
"Back": "Назад",
"List": "Список",
"NumberLabels": "{count, plural, =0 {нет меток} one {# метка} few {# метки} other {# меток}}",
-
"CategoryBacklog": "Пул",
"CategoryUnstarted": "Не запущенные",
"CategoryStarted": "Запущенный",
"CategoryCompleted": "Завершенные",
"CategoryCanceled": "Отменённые",
-
"Title": "Заголовок",
"Name": "Имя",
"Description": "Описание",
@@ -83,8 +81,8 @@
"AssignTo": "Назначить...",
"AssignedTo": "Назначен на {value}",
"Parent": "Родительская задача",
- "SetParent": "Задать родительскую задачу\u2026",
- "ChangeParent": "Изменить родительскую задачу\u2026",
+ "SetParent": "Задать родительскую задачу…",
+ "ChangeParent": "Изменить родительскую задачу…",
"RemoveParent": "Удалить родительскую задачу",
"OpenParent": "Открыть родительскую задачу",
"SubIssues": "Подзадачи",
@@ -98,8 +96,8 @@
"Labels": "Метки",
"Component": "Компонент",
"Space": "",
- "SetDueDate": "Указать срок выполнения\u2026",
- "ChangeDueDate": "Изменить срок выполнения\u2026",
+ "SetDueDate": "Указать срок выполнения…",
+ "ChangeDueDate": "Изменить срок выполнения…",
"ModificationDate": "Изменено {value}",
"Project": "Проект",
"Issue": "Задача",
@@ -111,9 +109,9 @@
"TypeIssuePriority": "Приоритет задачи",
"IssueTitlePlaceholder": "Имя задачи",
"SubIssueTitlePlaceholder": "Имя подзадачи",
- "IssueDescriptionPlaceholder": "Добавить описание\u2026",
+ "IssueDescriptionPlaceholder": "Добавить описание…",
"SubIssueDescriptionPlaceholder": "Описание подзадачи",
- "AddIssueTooltip": "Добавить задачу\u2026",
+ "AddIssueTooltip": "Добавить задачу…",
"NewIssueDialogClose": "Вы действительно хотите закрыть окно?",
"NewIssueDialogCloseNote": "Все внесенные изменения будут потеряны",
"RemoveComponentDialogClose": "Удалить компонент?",
@@ -137,23 +135,21 @@
"CopyIssueBranch": "Копировать имя ветки Git в буфер обмена",
"CopyIssueTitle": "Копировать имя задачи в буфер обмена",
"AssetLabel": "Asset",
- "AddToComponent": "Добавить в компонент\u2026",
- "MoveToComponent": "Переместить в компонент\u2026",
+ "AddToComponent": "Добавить в компонент…",
+ "MoveToComponent": "Переместить в компонент…",
"NoComponent": "Без компонента",
"ComponentLeadTitle": "Руководитель компонента",
"ComponentMembersTitle": "Участники компонента",
- "ComponentLeadSearchPlaceholder": "Назначьте руководителя компонента\u2026",
- "ComponentMembersSearchPlaceholder": "Изменить участников компонента\u2026",
+ "ComponentLeadSearchPlaceholder": "Назначьте руководителя компонента…",
+ "ComponentMembersSearchPlaceholder": "Изменить участников компонента…",
"MoveToProject": "Изменить проект",
"Duplicate": "Дублировать",
-
"GotoIssues": "Перейти к задачам",
"GotoActive": "Перейти к активным задачам",
"GotoBacklog": "Перейти к пулу задач",
"GotoComponents": "Перейти к компоненту",
"GotoMyIssues": "Перейти к моим задачам",
"GotoTrackerApplication": "Перейти к приложению Трекер",
-
"CreatedOne": "Создана",
"MoveIssues": "Переместить задачи",
"MoveIssuesDescription": "Выберите проект, в который вы хотите переместить задачи",
@@ -165,7 +161,6 @@
"Replacement": "ЗАМЕНА",
"Original": "ОРИГИНАЛ",
"OriginalDescription": "Элементы из этой секции будут созданы в новом проекте",
-
"Relations": "Зависимости",
"RemoveRelation": "Удалить зависимость...",
"AddBlockedBy": "Отметить как блокируемую...",
@@ -180,7 +175,6 @@
"Blocks": "Блокирует",
"Related": "Связан",
"RelatedIssues": "Связанные задачи",
-
"EditIssue": "Редактирование {title}",
"EditWorkflowStatuses": "Редактировать статусы задач",
"EditProject": "Редактировать проект",
@@ -195,21 +189,17 @@
"DeleteWorkflowStatus": "Удалить статус задачи",
"DeleteWorkflowStatusConfirm": "Вы действительно хотите удалить \"{status}\" статус?",
"DeleteWorkflowStatusErrorDescription": "Статус \"{status}\" {count, plural, one {имеет # задача} few {имеет $ задачи} other {имеют # задач}}. Пожалуйста, выберите статус для перемещения.",
-
"Save": "Сохранить",
"IncludeItemsThatMatch": "Включить элементы, которые соответствуют",
"AnyFilter": "любому фильтру",
"AllFilters": "всем фильтрам",
"NoDescription": "Нет описания",
"SearchIssue": "Поиск задачи...",
-
"StatusHistory": "История состояний",
"NewSubIssue": "Добавить подзадачу...",
"AddLabel": "Добавить метку",
-
"DeleteIssue": "Удалить {issueCount, plural, =1 {задачу} one {# задачу} few {# задачи} other {задач}}",
"DeleteIssueConfirm": "Вы действительно хотите удалить {issueCount, plural, =1 {задачу} one {# задачу} few {# задачи} other {задач}}{subIssueCount, plural, =0 {?} =1 { и подзадачу?} one { и # подзадачу?} other { и # подзадач?}}",
-
"Milestone": "Этап",
"NoMilestone": "Без Этапа",
"MoveToMilestone": "Выбрать Этап",
@@ -220,13 +210,10 @@
"ClosedMilestones": "Завершено",
"AddToMilestone": "Добавить в Этап",
"MilestoneNamePlaceholder": "Название Этапа",
-
"NewMilestone": "Новый Этап",
"CreateMilestone": "Создать",
-
"MoveAndDeleteMilestone": "Переместить Задачи в {newMilestone} и Удалить {deleteMilestone}",
"MoveAndDeleteMilestoneConfirm": "Вы действительно хотите удалить этап и перенести задачи в другой?",
-
"Estimation": "Оценка",
"ReportedTime": "Потраченное времени",
"RemainingTime": "Осталось времени",
@@ -245,11 +232,9 @@
"CapacityValue": "из {value} д",
"NewRelatedIssue": "Завести связанную задачу",
"RelatedIssuesNotFound": "Связанные задачи не найдены",
-
"AddedReference": "Добавлена зависимость",
"AddedAsBlocked": "Отмечено как заблокировано",
"AddedAsBlocking": "Отмечено как блокирующее",
-
"IssueTemplate": "Шаблон",
"IssueTemplates": "Шаблоны",
"NewProcess": "Новый шаблон",
@@ -258,12 +243,10 @@
"TemplateReplace": "Вы хотите применить выбранный шаблон?",
"TemplateReplaceConfirm": "Все внесенные изменения в будут заменены значениями из нового шаблона",
"Apply": "Применить",
-
"CurrentWorkDay": "Текущий Рабочий День",
"PreviousWorkDay": "Предыдущий Рабочий День",
"TimeReportDayTypeLabel": "Выберите тип дня для временного отчета",
"DefaultAssignee": "Исполнитель задачи по умолчанию",
-
"SevenHoursLength": "Семь Часов",
"EightHoursLength": "Восемь Часов",
"HourLabel": "ч",
@@ -293,7 +276,45 @@
"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",
+ "GanttDragConflict": "Issue was changed by someone else, please retry",
+ "GanttDragFailed": "Failed to apply Gantt edit",
+ "GanttDragNoPermission": "You don't have permission to edit this issue",
+ "GanttDragValidation": "Date is not valid: {reason}",
+ "GanttResizingTooltip": "{from} days → {to} days ({delta} d)",
+ "SetStartDate": "Set start date…",
+ "Hierarchy": "Hierarchy",
+ "LinkExistingSubIssue": "Link existing as sub-issue…",
+ "SetParentIssueLabel": "Set parent issue…",
+ "GanttDragToSchedule": "Drag to schedule",
+ "GanttDurationTooltip": "Duration: {days} d",
+ "GanttConfirmMove": "Confirm before moving an issue",
+ "GanttConfirmResize": "Confirm before resizing an issue",
+ "GanttConfirmMoveTitle": "Move issue?",
+ "GanttConfirmResizeTitle": "Change issue dates?",
+ "GanttConfirmMoveBody": "Move {title} to {start} – {due}?",
+ "GanttConfirmResizeBody": "Change {title} to {start} – {due}?",
+ "GanttConfirmApply": "Apply",
+ "GanttAriaResizeStart": "Resize start date",
+ "GanttAriaResizeEnd": "Resize due date"
},
"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/LinkSubIssueActionPopup.svelte b/plugins/tracker-resources/src/components/LinkSubIssueActionPopup.svelte
new file mode 100644
index 00000000000..dc46174e34c
--- /dev/null
+++ b/plugins/tracker-resources/src/components/LinkSubIssueActionPopup.svelte
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+ {issue.title}
+
+
+
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..a0cf5621d44
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/GanttBar.svelte
@@ -0,0 +1,375 @@
+
+
+
+{#if visible}
+ {@const barY = row.y + 6}
+ {@const barH = row.height - 12}
+ {#if isSummary}
+
+ {#if editable && !isMilestoneSummary}
+
+
+
+ {#if w >= 18}
+
+
+
+
+ {/if}
+ {/if}
+
+
+
+ {#if barLabel !== ''}
+ {barLabel}
+ {/if}
+
{tooltipText}
+ {:else}
+
+
+ {#if editable && w >= 18}
+
+
+
+
+ {/if}
+ {#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..59fc9815c64
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/GanttCanvas.svelte
@@ -0,0 +1,237 @@
+
+
+
+
+
+
diff --git a/plugins/tracker-resources/src/components/gantt/GanttConfirmCommitPopup.svelte b/plugins/tracker-resources/src/components/gantt/GanttConfirmCommitPopup.svelte
new file mode 100644
index 00000000000..0ffb580704f
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/GanttConfirmCommitPopup.svelte
@@ -0,0 +1,80 @@
+
+
+
+
+
+
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..d4da849aeb6
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/GanttHeader.svelte
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
diff --git a/plugins/tracker-resources/src/components/gantt/GanttHierarchySubmenu.svelte b/plugins/tracker-resources/src/components/gantt/GanttHierarchySubmenu.svelte
new file mode 100644
index 00000000000..be8e78b5142
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/GanttHierarchySubmenu.svelte
@@ -0,0 +1,64 @@
+
+
+
+
diff --git a/plugins/tracker-resources/src/components/gantt/GanttResizeOverlay.svelte b/plugins/tracker-resources/src/components/gantt/GanttResizeOverlay.svelte
new file mode 100644
index 00000000000..fce0121a0b6
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/GanttResizeOverlay.svelte
@@ -0,0 +1,144 @@
+
+
+
+{#if state.kind !== 'idle' && state.kind !== 'hover-bar' && geom !== null}
+
+
+{/if}
+
+{#if gx !== null}
+
+{/if}
+
+{#if pd !== null && gx !== null}
+
+
+ {fmt(pd)}
+
+{/if}
+
+{#if tip !== null && gx !== null}
+
+
+ {tip}
+
+{/if}
+
+
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..0ea134e95ad
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/GanttSidebar.svelte
@@ -0,0 +1,373 @@
+
+
+
+
+
+
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..fdef34fcbc6
--- /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..f4d355a9aba
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/GanttView.svelte
@@ -0,0 +1,1529 @@
+
+
+
+
+
+ {#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__/drag-controller.test.ts b/plugins/tracker-resources/src/components/gantt/lib/__tests__/drag-controller.test.ts
new file mode 100644
index 00000000000..12b59cc2623
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/lib/__tests__/drag-controller.test.ts
@@ -0,0 +1,263 @@
+//
+// Copyright © 2026 Hardcore Engineering Inc.
+// SPDX-License-Identifier: EPL-2.0
+//
+
+import type { Issue } from '@hcengineering/tracker'
+import type { Ref } from '@hcengineering/core'
+import { reduce } from '../drag-controller'
+import { createTimeScale, snapToUtcMidnight } from '../time-scale'
+import type { DragState } from '../types'
+
+const ts = createTimeScale('week', Date.UTC(2026, 0, 1))
+const issueRef = 'issue-1' as Ref
+
+const issue: Issue = {
+ _id: 'issue-1' as Ref,
+ _class: 'tracker:class:Issue' as Issue['_class'],
+ space: 'space-1' as Issue['space'],
+ startDate: Date.UTC(2026, 0, 5),
+ dueDate: Date.UTC(2026, 0, 12)
+ // The reducer only touches startDate/dueDate; the rest is type-padding.
+} as unknown as Issue
+
+describe('drag-controller — idle transitions', () => {
+ const idle: DragState = { kind: 'idle' }
+
+ it('mouseenter-bar moves idle → hover-bar', () => {
+ const next = reduce(idle, { type: 'mouseenter-bar', issueId: issueRef, edge: 'body' }, ts)
+ expect(next).toEqual({ kind: 'hover-bar', issueId: issueRef, edge: 'body' })
+ })
+
+ it('mouseleave-bar stays idle when already idle', () => {
+ const next = reduce(idle, { type: 'mouseleave-bar' }, ts)
+ expect(next).toEqual(idle)
+ })
+
+ it('mousemove stays idle when no drag is active', () => {
+ const next = reduce(idle, { type: 'mousemove', cursorX: 100 }, ts)
+ expect(next).toEqual(idle)
+ })
+
+ it('mouseup stays idle when no drag is active', () => {
+ const next = reduce(idle, { type: 'mouseup' }, ts)
+ expect(next).toEqual(idle)
+ })
+})
+
+describe('drag-controller — body drag', () => {
+ it('mousedown-bar on edge=body transitions hover → dragging-body', () => {
+ const hover: DragState = { kind: 'hover-bar', issueId: issue._id, edge: 'body' }
+ const next = reduce(hover, { type: 'mousedown-bar', issue, edge: 'body', cursorX: 200 }, ts)
+ expect(next.kind).toBe('dragging-body')
+ if (next.kind !== 'dragging-body') return
+ expect(next.issue._id).toBe(issue._id)
+ expect(next.originStart).toBe(issue.startDate)
+ expect(next.originDue).toBe(issue.dueDate)
+ expect(next.cursorStartX).toBe(200)
+ expect(next.previewStart).toBe(issue.startDate)
+ expect(next.previewDue).toBe(issue.dueDate)
+ })
+
+ it('mousemove shifts both preview dates by snapped delta', () => {
+ const dragging: DragState = {
+ kind: 'dragging-body',
+ issue,
+ originStart: issue.startDate as number,
+ originDue: issue.dueDate as number,
+ cursorStartX: 200,
+ previewStart: issue.startDate as number,
+ previewDue: issue.dueDate as number
+ }
+ // Week-zoom = 14 px/day → 28 px = 2 days
+ const next = reduce(dragging, { type: 'mousemove', cursorX: 228 }, ts)
+ if (next.kind !== 'dragging-body') throw new Error('expected dragging-body')
+ expect(next.previewStart).toBe((issue.startDate as number) + 2 * 86_400_000)
+ expect(next.previewDue).toBe((issue.dueDate as number) + 2 * 86_400_000)
+ })
+
+ it('mouseup returns dragging-body → idle', () => {
+ const dragging: DragState = {
+ kind: 'dragging-body',
+ issue,
+ originStart: issue.startDate as number,
+ originDue: issue.dueDate as number,
+ cursorStartX: 200,
+ previewStart: issue.startDate as number,
+ previewDue: issue.dueDate as number
+ }
+ const next = reduce(dragging, { type: 'mouseup' }, ts)
+ expect(next).toEqual({ kind: 'idle' })
+ })
+
+ it('cancel returns dragging-body → idle without applying the move', () => {
+ const dragging: DragState = {
+ kind: 'dragging-body',
+ issue,
+ originStart: issue.startDate as number,
+ originDue: issue.dueDate as number,
+ cursorStartX: 200,
+ previewStart: (issue.startDate as number) + 5 * 86_400_000,
+ previewDue: (issue.dueDate as number) + 5 * 86_400_000
+ }
+ const next = reduce(dragging, { type: 'cancel' }, ts)
+ expect(next).toEqual({ kind: 'idle' })
+ })
+})
+
+describe('drag-controller — resize-left', () => {
+ it('mousedown-bar on edge=left transitions hover → resizing-left', () => {
+ const hover: DragState = { kind: 'hover-bar', issueId: issue._id, edge: 'left' }
+ const next = reduce(hover, { type: 'mousedown-bar', issue, edge: 'left', cursorX: 50 }, ts)
+ expect(next.kind).toBe('resizing-left')
+ if (next.kind !== 'resizing-left') return
+ expect(next.previewStart).toBe(issue.startDate)
+ expect(next.originDue).toBe(issue.dueDate)
+ })
+
+ it('resize-left mousemove updates previewStart but not originDue', () => {
+ const resizing: DragState = {
+ kind: 'resizing-left',
+ issue,
+ originStart: issue.startDate as number,
+ originDue: issue.dueDate as number,
+ cursorStartX: 50,
+ previewStart: issue.startDate as number
+ }
+ // 14 px = 1 day at week zoom
+ const next = reduce(resizing, { type: 'mousemove', cursorX: 64 }, ts)
+ if (next.kind !== 'resizing-left') throw new Error('expected resizing-left')
+ expect(next.previewStart).toBe((issue.startDate as number) + 1 * 86_400_000)
+ })
+
+ it('resize-left clamps previewStart to be ≤ originDue', () => {
+ const resizing: DragState = {
+ kind: 'resizing-left',
+ issue,
+ originStart: issue.startDate as number,
+ originDue: issue.dueDate as number,
+ cursorStartX: 50,
+ previewStart: issue.startDate as number
+ }
+ // Move 100 days right — past the due date
+ const next = reduce(resizing, { type: 'mousemove', cursorX: 50 + 100 * 14 }, ts)
+ if (next.kind !== 'resizing-left') throw new Error('expected resizing-left')
+ expect(next.previewStart).toBe(issue.dueDate)
+ })
+})
+
+describe('drag-controller — resize-right', () => {
+ it('mousedown-bar on edge=right transitions hover → resizing-right', () => {
+ const hover: DragState = { kind: 'hover-bar', issueId: issue._id, edge: 'right' }
+ const next = reduce(hover, { type: 'mousedown-bar', issue, edge: 'right', cursorX: 250 }, ts)
+ expect(next.kind).toBe('resizing-right')
+ if (next.kind !== 'resizing-right') return
+ expect(next.previewDue).toBe(issue.dueDate)
+ expect(next.originStart).toBe(issue.startDate)
+ })
+
+ it('resize-right mousemove updates previewDue but not originStart', () => {
+ const resizing: DragState = {
+ kind: 'resizing-right',
+ issue,
+ originStart: issue.startDate as number,
+ originDue: issue.dueDate as number,
+ cursorStartX: 250,
+ previewDue: issue.dueDate as number
+ }
+ const next = reduce(resizing, { type: 'mousemove', cursorX: 264 }, ts)
+ if (next.kind !== 'resizing-right') throw new Error('expected resizing-right')
+ expect(next.previewDue).toBe((issue.dueDate as number) + 1 * 86_400_000)
+ })
+
+ it('resize-right clamps previewDue to be ≥ originStart', () => {
+ const resizing: DragState = {
+ kind: 'resizing-right',
+ issue,
+ originStart: issue.startDate as number,
+ originDue: issue.dueDate as number,
+ cursorStartX: 250,
+ previewDue: issue.dueDate as number
+ }
+ // Move 100 days left — past the start
+ const next = reduce(resizing, { type: 'mousemove', cursorX: 250 - 100 * 14 }, ts)
+ if (next.kind !== 'resizing-right') throw new Error('expected resizing-right')
+ expect(next.previewDue).toBe(issue.startDate)
+ })
+})
+
+describe('drag-controller — unscheduled drag', () => {
+ const undated: Issue = {
+ _id: 'u' as Ref,
+ parents: [],
+ startDate: null,
+ dueDate: null
+ } as unknown as Issue
+
+ it('mousedown-unscheduled from idle transitions to dragging-unscheduled with origin fields', () => {
+ const next = reduce({ kind: 'idle' }, { type: 'mousedown-unscheduled', issue: undated, cursorX: 100 }, ts)
+ expect(next.kind).toBe('dragging-unscheduled')
+ if (next.kind !== 'dragging-unscheduled') return
+ expect(next.previewStart).toBeGreaterThan(0)
+ expect(next.previewDue).toBe(next.previewStart + 86_400_000) // default 1-day span
+ // Origin fields are populated so commitDrag/overlay treat unscheduled like
+ // a regular drag with an implicit "today" anchor.
+ expect(next.originStart).toBe(next.previewStart)
+ expect(next.originDue).toBe(next.previewDue)
+ // Guard against click-without-drag scheduling to today: hasCanvasTarget
+ // is false until a real canvas-X is seen on mousemove.
+ expect(next.hasCanvasTarget).toBe(false)
+ })
+
+ it('mousemove without canvasX keeps hasCanvasTarget false', () => {
+ const start: DragState = {
+ kind: 'dragging-unscheduled',
+ issue: undated,
+ originStart: 1_700_000_000_000,
+ originDue: 1_700_000_000_000 + 86_400_000,
+ cursorStartX: 100,
+ previewStart: 1_700_000_000_000,
+ previewDue: 1_700_000_000_000 + 86_400_000,
+ hasCanvasTarget: false
+ }
+ const next = reduce(start, { type: 'mousemove', cursorX: 200 }, ts)
+ if (next.kind !== 'dragging-unscheduled') throw new Error('expected dragging-unscheduled')
+ expect(next.hasCanvasTarget).toBe(false)
+ expect(next.previewStart).toBe(start.previewStart)
+ })
+
+ it('mousemove with canvasX flips hasCanvasTarget true and snaps previewStart', () => {
+ const start: DragState = {
+ kind: 'dragging-unscheduled',
+ issue: undated,
+ originStart: 1_700_000_000_000,
+ originDue: 1_700_000_000_000 + 86_400_000,
+ cursorStartX: 100,
+ previewStart: 1_700_000_000_000,
+ previewDue: 1_700_000_000_000 + 86_400_000,
+ hasCanvasTarget: false
+ }
+ // canvasX = 7 * pxPerDay (week zoom = 14 px/day) → 7 days past origin
+ const next = reduce(start, { type: 'mousemove', cursorX: 200, canvasX: 7 * 14 }, ts)
+ if (next.kind !== 'dragging-unscheduled') throw new Error('expected dragging-unscheduled')
+ expect(next.hasCanvasTarget).toBe(true)
+ expect(next.previewStart).toBe(snapToUtcMidnight(ts.fromX(7 * 14)))
+ expect(next.previewDue).toBe(next.previewStart + 86_400_000)
+ })
+})
+
+describe('drag-controller — direct idle → drag (Playwright + edge-case)', () => {
+ it('mousedown-bar from idle transitions directly to dragging-body', () => {
+ const next = reduce({ kind: 'idle' }, { type: 'mousedown-bar', issue, edge: 'body', cursorX: 200 }, ts)
+ expect(next.kind).toBe('dragging-body')
+ if (next.kind !== 'dragging-body') return
+ expect(next.previewStart).toBe(issue.startDate)
+ expect(next.previewDue).toBe(issue.dueDate)
+ expect(next.cursorStartX).toBe(200)
+ })
+
+ it('mousedown-bar (edge=left) from idle goes directly to resizing-left', () => {
+ const next = reduce({ kind: 'idle' }, { type: 'mousedown-bar', issue, edge: 'left', cursorX: 50 }, ts)
+ expect(next.kind).toBe('resizing-left')
+ })
+})
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__/link-sub-issue-cycle.test.ts b/plugins/tracker-resources/src/components/gantt/lib/__tests__/link-sub-issue-cycle.test.ts
new file mode 100644
index 00000000000..9c616ff8197
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/lib/__tests__/link-sub-issue-cycle.test.ts
@@ -0,0 +1,97 @@
+//
+// Copyright © 2026 Hardcore Engineering Inc.
+// SPDX-License-Identifier: EPL-2.0
+//
+
+/**
+ * Standalone test for the cycle-safe ignore-set used by
+ * LinkSubIssueActionPopup. The component itself can't be exercised
+ * headlessly (it's a Svelte popup with DOM dependencies), but the
+ * computeIgnoreSet function is pure and worth fencing in jest so a
+ * future refactor doesn't silently regress the cycle protection.
+ *
+ * The function below is a duplicate of the one in
+ * `LinkSubIssueActionPopup.svelte` — kept in sync deliberately.
+ * review note (2026-05-11): flagged the absence of a cycle test.
+ */
+
+import type { Issue } from '@hcengineering/tracker'
+import type { Ref } from '@hcengineering/core'
+
+function computeIgnoreSet (root: Issue, all: Issue[]): Set[> {
+ const ignored = new Set][>([root._id])
+ if (Array.isArray(root.parents)) {
+ for (const p of root.parents as Array<{ parentId: Ref }>) {
+ if (p?.parentId !== undefined) ignored.add(p.parentId)
+ }
+ }
+ const childrenByParent = new Map][, Issue[]>()
+ for (const i of all) {
+ const parentId = i.parents?.[0]?.parentId as Ref | undefined
+ if (parentId === undefined) continue
+ const bucket = childrenByParent.get(parentId)
+ if (bucket === undefined) childrenByParent.set(parentId, [i])
+ else bucket.push(i)
+ }
+ const queue: Issue[] = [...(childrenByParent.get(root._id) ?? [])]
+ while (queue.length > 0) {
+ const next = queue.shift() as Issue
+ if (ignored.has(next._id)) continue
+ ignored.add(next._id)
+ const children = childrenByParent.get(next._id)
+ if (children !== undefined) {
+ for (const c of children) queue.push(c)
+ }
+ }
+ return ignored
+}
+
+function mk (id: string, parents: string[] = []): Issue {
+ return {
+ _id: id as Ref,
+ parents: parents.map((p) => ({ parentId: p as Ref, parentTitle: '', space: 'sp' }))
+ } as unknown as Issue
+}
+
+describe('LinkSubIssue — cycle-safe ignore set', () => {
+ it('excludes self', () => {
+ const root = mk('r')
+ const result = computeIgnoreSet(root, [root])
+ expect(result.has(root._id)).toBe(true)
+ })
+
+ it('excludes direct ancestors via parents[]', () => {
+ const root = mk('r', ['p', 'gp']) // r is child of p, p is child of gp
+ const result = computeIgnoreSet(root, [root])
+ expect(result.has('p' as Ref)).toBe(true)
+ expect(result.has('gp' as Ref)).toBe(true)
+ })
+
+ it('excludes direct + transitive descendants', () => {
+ const root = mk('r')
+ const child = mk('c', ['r'])
+ const grand = mk('g', ['c', 'r']) // transitive parents — direct = c
+ const result = computeIgnoreSet(root, [root, child, grand])
+ expect(result.has('c' as Ref)).toBe(true)
+ expect(result.has('g' as Ref)).toBe(true)
+ })
+
+ it('does NOT exclude an unrelated sibling of root', () => {
+ const root = mk('r')
+ const sibling = mk('s')
+ const result = computeIgnoreSet(root, [root, sibling])
+ expect(result.has('s' as Ref)).toBe(false)
+ })
+
+ it('cycle-safe: a self-referential issue does not infinite-loop', () => {
+ const root = mk('r')
+ const cyclic = mk('x', ['x'])
+ expect(() => computeIgnoreSet(root, [root, cyclic])).not.toThrow()
+ })
+
+ it('cycle-safe: mutual parent loop (a<->b) terminates', () => {
+ const a = mk('a', ['b'])
+ const b = mk('b', ['a'])
+ expect(() => computeIgnoreSet(a, [a, b])).not.toThrow()
+ })
+})
diff --git a/plugins/tracker-resources/src/components/gantt/lib/__tests__/scheduler.test.ts b/plugins/tracker-resources/src/components/gantt/lib/__tests__/scheduler.test.ts
new file mode 100644
index 00000000000..05ec881025d
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/lib/__tests__/scheduler.test.ts
@@ -0,0 +1,90 @@
+//
+// Copyright © 2026 Hardcore Engineering Inc.
+// SPDX-License-Identifier: EPL-2.0
+//
+
+import type { Issue } from '@hcengineering/tracker'
+import type { Ref } from '@hcengineering/core'
+import { descendantsWithDates } from '../scheduler'
+
+/**
+ * Huly's Issue parent relation lives on `issue.parents` (an array of
+ * IssueParentInfo records, root-most first). The first entry is the
+ * direct parent. layout.ts:52 + GanttView.svelte:192 both use this
+ * pattern, so the scheduler walks the same edge.
+ */
+function mkIssue (id: string, parent: string | null, startDate: number | null, dueDate: number | null): Issue {
+ return {
+ _id: id as Ref,
+ parents: parent !== null ? [{ parentId: parent as Ref, parentTitle: '', space: 'sp' }] : [],
+ startDate,
+ dueDate
+ } as unknown as Issue
+}
+
+describe('descendantsWithDates', () => {
+ it('returns empty list for a leaf with no children', () => {
+ const root = mkIssue('a', null, 100, 200)
+ expect(descendantsWithDates(root, [root])).toEqual([])
+ })
+
+ it('returns direct children that have both startDate and dueDate', () => {
+ const parent = mkIssue('p', null, 100, 200)
+ const c1 = mkIssue('c1', 'p', 110, 150)
+ const c2 = mkIssue('c2', 'p', 160, 190)
+ const result = descendantsWithDates(parent, [parent, c1, c2])
+ expect(result.map((i) => i._id)).toEqual(['c1', 'c2'])
+ })
+
+ it('skips children with null startDate or dueDate', () => {
+ const parent = mkIssue('p', null, 100, 200)
+ const dated = mkIssue('c1', 'p', 110, 150)
+ const undated = mkIssue('c2', 'p', null, null)
+ const halfDated = mkIssue('c3', 'p', 110, null)
+ const result = descendantsWithDates(parent, [parent, dated, undated, halfDated])
+ expect(result.map((i) => i._id)).toEqual(['c1'])
+ })
+
+ it('walks multi-level tree', () => {
+ const root = mkIssue('r', null, 100, 200)
+ const c1 = mkIssue('c1', 'r', 110, 150)
+ const g1 = mkIssue('g1', 'c1', 115, 140)
+ const g2 = mkIssue('g2', 'c1', 120, 130)
+ const result = descendantsWithDates(root, [root, c1, g1, g2])
+ expect(result.map((i) => i._id).sort()).toEqual(['c1', 'g1', 'g2'])
+ })
+
+ it('cycle-safe: does not infinite-loop when an issue lists itself as parent', () => {
+ const root = mkIssue('r', null, 100, 200)
+ const cyclic = mkIssue('x', 'x', 110, 150)
+ expect(() => descendantsWithDates(root, [root, cyclic])).not.toThrow()
+ })
+
+ it('cycle-safe: handles parent loops via two-way reference', () => {
+ const a = mkIssue('a', 'b', 100, 200)
+ const b = mkIssue('b', 'a', 110, 150)
+ expect(() => descendantsWithDates(a, [a, b])).not.toThrow()
+ })
+
+ it('only the direct parent (parents[0]) is followed, not transitive parents[]', () => {
+ // Huly's parents[] also lists grandparents for breadcrumb purposes.
+ // descendantsWithDates must only count children whose direct parent
+ // (parents[0].parentId) equals the root; otherwise a grandchild would
+ // be double-walked.
+ const root = mkIssue('r', null, 100, 200)
+ const child = mkIssue('c', 'r', 110, 150)
+ const grand: Issue = {
+ _id: 'g' as Ref,
+ parents: [
+ { parentId: 'c' as Ref, parentTitle: '', space: 'sp' },
+ { parentId: 'r' as Ref, parentTitle: '', space: 'sp' }
+ ],
+ startDate: 115,
+ dueDate: 140
+ } as unknown as Issue
+ const result = descendantsWithDates(root, [root, child, grand])
+ // Both c and g are descendants of r, but g must be reached *via* c — not
+ // counted twice. Use a Set check on the IDs.
+ expect(new Set(result.map((i) => i._id))).toEqual(new Set(['c', 'g']))
+ })
+})
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..03d8b689edc
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/lib/__tests__/time-scale.test.ts
@@ -0,0 +1,113 @@
+//
+// 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)
+ }
+ })
+})
+
+describe('time-scale — secondary label (year/month supra row)', () => {
+ const DAY = 86_400_000
+
+ it('day-zoom: secondaryLabel set on 1st of month and on first visible tick', () => {
+ const scale = createTimeScale('day', Date.UTC(2026, 0, 1))
+ // Span 2 months: Jan 28 – Feb 5
+ const ticks = scale.ticks([Date.UTC(2026, 0, 28), Date.UTC(2026, 1, 5)])
+ const withSecondary = ticks.filter((t) => t.secondaryLabel !== undefined)
+ // Expect: first visible tick (Jan 28) + Feb 1
+ expect(withSecondary).toHaveLength(2)
+ expect(withSecondary[0].secondaryLabel).toMatch(/jan/i)
+ expect(new Date(withSecondary[1].date).getUTCDate()).toBe(1)
+ expect(withSecondary[1].secondaryLabel).toMatch(/feb/i)
+ })
+
+ it('week-zoom: secondaryLabel = year on first visible week + first week of new year', () => {
+ const scale = createTimeScale('week', Date.UTC(2026, 11, 1))
+ // Span across the 2026/2027 year boundary
+ const ticks = scale.ticks([Date.UTC(2026, 11, 1), Date.UTC(2027, 0, 31)])
+ const withSecondary = ticks.filter((t) => t.secondaryLabel !== undefined)
+ expect(withSecondary.length).toBeGreaterThanOrEqual(2)
+ expect(withSecondary[0].secondaryLabel).toBe('2026')
+ expect(withSecondary[1].secondaryLabel).toBe('2027')
+ })
+
+ it('quarter-zoom: label is "Qn" only, year goes to secondaryLabel', () => {
+ const scale = createTimeScale('quarter', Date.UTC(2026, 0, 1))
+ const ticks = scale.ticks([Date.UTC(2026, 0, 1), Date.UTC(2026, 11, 31)])
+ expect(ticks.map((t) => t.label)).toEqual(['Q1', 'Q2', 'Q3', 'Q4'])
+ expect(ticks[0].secondaryLabel).toBe('2026')
+ expect(ticks[1].secondaryLabel).toBeUndefined() // Q2 same year
+ void DAY
+ })
+})
diff --git a/plugins/tracker-resources/src/components/gantt/lib/drag-controller.ts b/plugins/tracker-resources/src/components/gantt/lib/drag-controller.ts
new file mode 100644
index 00000000000..c38ddc8f3b1
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/lib/drag-controller.ts
@@ -0,0 +1,163 @@
+//
+// Copyright © 2026 Hardcore Engineering Inc.
+// SPDX-License-Identifier: EPL-2.0
+//
+
+import type { DragEvent, DragState } from './types'
+import { snapToUtcMidnight } from './time-scale'
+import type { TimeScale } from './time-scale'
+
+/**
+ * Pure reduction over drag state. Given the current state and an input event
+ * (mouse or cancel), returns the next state. No IO; no DOM access; the time
+ * scale is passed in as a value so the reducer is fully deterministic and
+ * trivially testable.
+ */
+export function reduce (state: DragState, event: DragEvent, timeScale: TimeScale): DragState {
+ switch (state.kind) {
+ case 'idle':
+ return reduceFromIdle(state, event)
+ case 'hover-bar':
+ return reduceFromHover(state, event, timeScale)
+ case 'dragging-body':
+ case 'dragging-unscheduled':
+ case 'resizing-left':
+ case 'resizing-right':
+ return reduceFromActive(state, event, timeScale)
+ }
+}
+
+function reduceFromIdle (state: DragState & { kind: 'idle' }, event: DragEvent): DragState {
+ if (event.type === 'mouseenter-bar') {
+ return { kind: 'hover-bar', issueId: event.issueId, edge: event.edge }
+ }
+ // PR 3: allow direct idle → drag in one step. Real users always pass through
+ // hover-bar first (mouseenter fires before mousedown), but synthetic event
+ // dispatch (Playwright tests) and edge-cases where the bar is summoned under
+ // the cursor (e.g. after a re-render) can skip the hover state. Treat
+ // mousedown-bar as the start of a drag regardless.
+ if (event.type === 'mousedown-bar') {
+ if (event.issue.startDate == null || event.issue.dueDate == null) return state
+ const base = {
+ issue: event.issue,
+ originStart: event.issue.startDate,
+ originDue: event.issue.dueDate,
+ cursorStartX: event.cursorX
+ }
+ if (event.edge === 'body') {
+ return {
+ kind: 'dragging-body',
+ ...base,
+ previewStart: event.issue.startDate,
+ previewDue: event.issue.dueDate
+ }
+ }
+ if (event.edge === 'left') {
+ return { kind: 'resizing-left', ...base, previewStart: event.issue.startDate }
+ }
+ if (event.edge === 'right') {
+ return { kind: 'resizing-right', ...base, previewDue: event.issue.dueDate }
+ }
+ }
+ if (event.type === 'mousedown-unscheduled') {
+ // Default to "today" for both dates; cursor movement during the drag
+ // shifts them in lockstep just like dragging-body. originStart/originDue
+ // are recorded so commitDrag() and the resize-overlay can share the same
+ // code path as dragging-body. hasCanvasTarget starts false — only a real
+ // mousemove with canvasX flips it true and unlocks the commit.
+ const today = snapToUtcMidnight(Date.now())
+ return {
+ kind: 'dragging-unscheduled',
+ issue: event.issue,
+ originStart: today,
+ originDue: today + 86_400_000,
+ cursorStartX: event.cursorX,
+ previewStart: today,
+ previewDue: today + 86_400_000,
+ hasCanvasTarget: false
+ }
+ }
+ return state
+}
+
+function reduceFromHover (state: DragState & { kind: 'hover-bar' }, event: DragEvent, timeScale: TimeScale): DragState {
+ if (event.type === 'mouseleave-bar') {
+ return { kind: 'idle' }
+ }
+ if (event.type === 'mousedown-bar') {
+ if (event.issue.startDate == null || event.issue.dueDate == null) return state
+ const base = {
+ issue: event.issue,
+ originStart: event.issue.startDate,
+ originDue: event.issue.dueDate,
+ cursorStartX: event.cursorX
+ }
+ if (event.edge === 'body') {
+ return {
+ kind: 'dragging-body',
+ ...base,
+ previewStart: event.issue.startDate,
+ previewDue: event.issue.dueDate
+ }
+ }
+ if (event.edge === 'left') {
+ return { kind: 'resizing-left', ...base, previewStart: event.issue.startDate }
+ }
+ if (event.edge === 'right') {
+ return { kind: 'resizing-right', ...base, previewDue: event.issue.dueDate }
+ }
+ }
+ void timeScale
+ return state
+}
+
+function reduceFromActive (state: DragState, event: DragEvent, timeScale: TimeScale): DragState {
+ if (event.type === 'mouseup' || event.type === 'cancel') {
+ return { kind: 'idle' }
+ }
+ if (event.type !== 'mousemove') return state
+
+ if (state.kind === 'dragging-body') {
+ const deltaPx = event.cursorX - state.cursorStartX
+ const deltaMs = (deltaPx / timeScale.pxPerDay) * 86_400_000
+ return {
+ ...state,
+ previewStart: snapToUtcMidnight(state.originStart + deltaMs),
+ previewDue: snapToUtcMidnight(state.originDue + deltaMs)
+ }
+ }
+
+ if (state.kind === 'resizing-left') {
+ const deltaPx = event.cursorX - state.cursorStartX
+ const deltaMs = (deltaPx / timeScale.pxPerDay) * 86_400_000
+ const candidate = snapToUtcMidnight(state.originStart + deltaMs)
+ // Clamp so previewStart never crosses originDue (would invert the bar).
+ return { ...state, previewStart: Math.min(candidate, state.originDue) }
+ }
+
+ if (state.kind === 'resizing-right') {
+ const deltaPx = event.cursorX - state.cursorStartX
+ const deltaMs = (deltaPx / timeScale.pxPerDay) * 86_400_000
+ const candidate = snapToUtcMidnight(state.originDue + deltaMs)
+ return { ...state, previewDue: Math.max(candidate, state.originStart) }
+ }
+
+ if (state.kind === 'dragging-unscheduled') {
+ // Only update the preview when the cursor is actually over the canvas.
+ if (event.canvasX === undefined) return state
+ const newStart = snapToUtcMidnight(timeScale.fromX(event.canvasX))
+ return {
+ ...state,
+ previewStart: newStart,
+ previewDue: newStart + 86_400_000,
+ hasCanvasTarget: true
+ }
+ }
+
+ return state
+}
+
+/** Convenience helper used by later tasks to clamp a date to a UTC midnight. */
+export function snapDate (t: number): number {
+ return snapToUtcMidnight(t)
+}
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/menu-actions.ts b/plugins/tracker-resources/src/components/gantt/lib/menu-actions.ts
new file mode 100644
index 00000000000..14e672d21d6
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/lib/menu-actions.ts
@@ -0,0 +1,105 @@
+//
+// Copyright © 2026 Hardcore Engineering Inc.
+// SPDX-License-Identifier: EPL-2.0
+//
+
+import type { Issue } from '@hcengineering/tracker'
+import type { Action as UiAction, PopupAlignment } from '@hcengineering/ui'
+import { DatePopup, NotificationSeverity, addNotification, showPopup } from '@hcengineering/ui'
+import Calendar from '@hcengineering/ui/src/components/icons/Calendar.svelte'
+import { getClient } from '@hcengineering/presentation'
+import { translate } from '@hcengineering/platform'
+import type { Timestamp } from '@hcengineering/core'
+import tracker from '../../../plugin'
+import { snapToUtcMidnight } from './time-scale'
+import GanttHierarchySubmenu from '../GanttHierarchySubmenu.svelte'
+
+const DAY_MS = 86_400_000
+
+/**
+ * Open Huly's DatePopup against the issue's startDate. On confirm, write the
+ * snapped value back via updateDoc. If both dates are null and the user picks
+ * a start, auto-fill dueDate = start + 1 day so the bar becomes visible on
+ * the canvas immediately (parent-spec §8.0 date-only semantics).
+ *
+ * Callback shape: DatePopup dispatches `close` with `{ value: Date | null }`,
+ * so the showPopup result handler receives `{ value: Date | null } | undefined`.
+ * `undefined` means dismissed; `value === null` means the user pressed Clear.
+ * Verified against packages/ui/src/components/calendar/DatePopup.svelte:72
+ * (`dispatch('close', { value: currentDate })`) and the existing
+ * DateEditor.svelte usage in plugins/calendar-resources.
+ *
+ * `tracker.action.SetDueDate` is already registered as a model action and is
+ * surfaced by Menu.svelte's auto-resolution; we do not add a local twin here.
+ */
+export function openSetStartDate (issue: Issue, anchor: PopupAlignment | undefined): void {
+ const client = getClient()
+ showPopup(
+ DatePopup,
+ {
+ currentDate: issue.startDate != null ? new Date(issue.startDate) : null,
+ withTime: false,
+ label: tracker.string.SetStartDate
+ },
+ anchor,
+ (result: { value: Date | null } | undefined) => {
+ if (result === undefined) return // dismissed
+ const picked = result.value
+ const newStart: Timestamp | null = picked === null ? null : snapToUtcMidnight(picked.getTime())
+ const patch: { startDate: Timestamp | null, dueDate?: Timestamp | null } = { startDate: newStart }
+ // Auto-fill due-date when both were null and the user is scheduling for the first time.
+ if (newStart !== null && issue.startDate == null && issue.dueDate == null) {
+ patch.dueDate = newStart + DAY_MS
+ }
+ // Surface failures (permission denied, validation, conflict) the same
+ // way commitDrag does (review note 2026-05-11) so the user gets
+ // visible feedback instead of a silent fire-and-forget.
+ client.updateDoc(issue._class, issue.space, issue._id, patch).catch(async (err) => {
+ const title = await translate(tracker.string.GanttDragFailed, {}, undefined)
+ addNotification(title, String(err), undefined as any, undefined, NotificationSeverity.Error)
+ })
+ }
+ )
+}
+
+/**
+ * Local-only ui.Action list appended to the Gantt context menu. Right now
+ * this is a single "Set start date" entry; "Set due date" is handled by the
+ * existing tracker.action.SetDueDate model action, which Menu.svelte
+ * resolves automatically.
+ *
+ * The anchor closure is passed in by the caller because at action-invocation
+ * time the original MouseEvent is no longer reachable — `Menu.svelte` calls
+ * `action(props, evt)` where `evt` is the menu-item click, not the original
+ * right-click. The Gantt context-menu trigger captures the right-click
+ * position via getEventPositionElement(ev) and passes it through.
+ */
+export function ganttExtraActions (issue: Issue, anchor: PopupAlignment | undefined): UiAction[] {
+ return [
+ {
+ label: tracker.string.SetStartDate,
+ icon: Calendar,
+ group: 'edit',
+ action: async () => {
+ openSetStartDate(issue, anchor)
+ }
+ },
+ {
+ // Hierarchy ▸ submenu: combines SetParent, Add sub-issue, Link existing
+ // as sub-issue into one Gantt entry. The two existing model actions
+ // (tracker:action:SetParent, tracker:action:NewSubIssue) are excluded
+ // from the parent menu via GANTT_MENU_EXCLUDED_ACTIONS in GanttView.
+ // `action` is required by the ui.Action interface but is a no-op for
+ // submenu entries — Menu.svelte routes the click to `component` when
+ // present (showActionPopup at packages/ui/src/components/Menu.svelte:82).
+ label: tracker.string.Hierarchy,
+ icon: tracker.icon.Parent,
+ group: 'associate',
+ action: async () => {
+ /* submenu — handled by component */
+ },
+ component: GanttHierarchySubmenu,
+ props: { issue }
+ }
+ ]
+}
diff --git a/plugins/tracker-resources/src/components/gantt/lib/scheduler.ts b/plugins/tracker-resources/src/components/gantt/lib/scheduler.ts
new file mode 100644
index 00000000000..23b0dae345e
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/lib/scheduler.ts
@@ -0,0 +1,48 @@
+//
+// Copyright © 2026 Hardcore Engineering Inc.
+// SPDX-License-Identifier: EPL-2.0
+//
+
+import type { Issue } from '@hcengineering/tracker'
+import type { Ref } from '@hcengineering/core'
+
+/**
+ * Return every descendant of `issue` that has both `startDate` and `dueDate`
+ * concretely set. Children/grandchildren are walked recursively via the
+ * `issue.parents[0].parentId` pointer — that is the direct-parent field used
+ * by the existing Gantt layout (`layout.ts:52`, `GanttView.svelte:192`).
+ * Cycle-safe: each issue id is only visited once, so a buggy outline with
+ * `a→b→a` is handled without an infinite loop.
+ */
+export function descendantsWithDates (issue: Issue, allIssues: Issue[]): Issue[] {
+ const childrenByParent = new Map][, Issue[]>()
+ for (const i of allIssues) {
+ const parent = i.parents?.[0]?.parentId as Ref | undefined
+ if (parent === undefined) continue
+ const bucket = childrenByParent.get(parent)
+ if (bucket === undefined) {
+ childrenByParent.set(parent, [i])
+ } else {
+ bucket.push(i)
+ }
+ }
+
+ const visited = new Set][>([issue._id])
+ const result: Issue[] = []
+ const queue: Issue[] = [...(childrenByParent.get(issue._id) ?? [])]
+
+ while (queue.length > 0) {
+ const next = queue.shift() as Issue
+ if (visited.has(next._id)) continue
+ visited.add(next._id)
+ if (next.startDate != null && next.dueDate != null) {
+ result.push(next)
+ }
+ const children = childrenByParent.get(next._id)
+ if (children !== undefined) {
+ for (const c of children) queue.push(c)
+ }
+ }
+
+ return result
+}
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..1791f868650
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/lib/time-scale.ts
@@ -0,0 +1,188 @@
+//
+// 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': {
+ // Secondary label: month name on the 1st of each month, or on the
+ // first visible tick (so the user sees a context label even if the
+ // viewport starts mid-month).
+ let lastMonth = -1
+ let first = true
+ while (cursor <= to) {
+ const d = new Date(cursor)
+ const isMonday = d.getUTCDay() === 1
+ const m = d.getUTCMonth()
+ let secondary: string | undefined
+ if (first || m !== lastMonth) {
+ secondary = d.toLocaleString(undefined, { month: 'short', timeZone: 'UTC' })
+ lastMonth = m
+ first = false
+ }
+ result.push({
+ date: cursor,
+ label: d.getUTCDate().toString(),
+ level: isMonday ? 'major' : 'minor',
+ secondaryLabel: secondary
+ })
+ cursor += DAY_MS
+ }
+ break
+ }
+ case 'week': {
+ // Secondary label: year on the first week of each year (or first
+ // visible week). Aligns the supra-row with year boundaries.
+ const d = new Date(cursor)
+ const dow = d.getUTCDay() // 0=Sun
+ const offsetToMonday = (1 - dow + 7) % 7
+ cursor += offsetToMonday * DAY_MS
+ let lastYear = -1
+ let first = true
+ while (cursor <= to) {
+ const c = new Date(cursor)
+ const isFirstWeekOfMonth = c.getUTCDate() <= 7
+ const y = c.getUTCFullYear()
+ let secondary: string | undefined
+ if (first || y !== lastYear) {
+ secondary = String(y)
+ lastYear = y
+ first = false
+ }
+ result.push({
+ date: cursor,
+ label: `W${isoWeekNumber(c)}`,
+ level: isFirstWeekOfMonth ? 'major' : 'minor',
+ secondaryLabel: secondary
+ })
+ cursor += 7 * DAY_MS
+ }
+ break
+ }
+ case 'month': {
+ // Secondary label: year on January (or first visible month). Was
+ // entirely missing before — without it, you couldn't tell which year
+ // a month belonged to in a multi-year span.
+ const start = new Date(cursor)
+ let y = start.getUTCFullYear()
+ let m = start.getUTCMonth()
+ cursor = Date.UTC(y, m, 1)
+ let lastYear = -1
+ let first = true
+ while (cursor <= to) {
+ const c = new Date(cursor)
+ const yr = c.getUTCFullYear()
+ let secondary: string | undefined
+ if (first || yr !== lastYear) {
+ secondary = String(yr)
+ lastYear = yr
+ first = false
+ }
+ result.push({
+ date: cursor,
+ label: c.toLocaleString(undefined, { month: 'short', timeZone: 'UTC' }),
+ level: c.getUTCMonth() === 0 ? 'major' : 'minor',
+ secondaryLabel: secondary
+ })
+ m += 1
+ if (m > 11) {
+ m = 0
+ y += 1
+ }
+ cursor = Date.UTC(y, m, 1)
+ }
+ break
+ }
+ case 'quarter': {
+ // Split the year out of the primary label and put it on the supra
+ // row — previously the label was "Q1 2026" side-by-side, which is
+ // visually noisy in a dense Quarter view.
+ const start = new Date(cursor)
+ let y = start.getUTCFullYear()
+ let q = Math.floor(start.getUTCMonth() / 3)
+ cursor = Date.UTC(y, q * 3, 1)
+ let lastYear = -1
+ let first = true
+ while (cursor <= to) {
+ const c = new Date(cursor)
+ const qNum = Math.floor(c.getUTCMonth() / 3) + 1
+ const yr = c.getUTCFullYear()
+ let secondary: string | undefined
+ if (first || yr !== lastYear) {
+ secondary = String(yr)
+ lastYear = yr
+ first = false
+ }
+ result.push({
+ date: cursor,
+ label: `Q${qNum}`,
+ level: qNum === 1 ? 'major' : 'minor',
+ secondaryLabel: secondary
+ })
+ 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..6c39f3fbd55
--- /dev/null
+++ b/plugins/tracker-resources/src/components/gantt/lib/types.ts
@@ -0,0 +1,145 @@
+//
+// 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 (primary row)
+ level: 'major' | 'minor' // major ticks render thicker + with text label
+ /**
+ * Optional supra-label rendered on a second header row above `label` when
+ * the segment changes from the previous tick. Day view sets it to month
+ * name on the 1st of each month; week view sets it to the year on the
+ * first week of each year; month view sets it to the year on January;
+ * quarter view sets it to the year on Q1.
+ */
+ secondaryLabel?: string
+}
+
+/** 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
+}
+
+/** Which part of a bar is being interacted with. */
+export type DragKind = 'body' | 'left' | 'right' | 'unscheduled'
+
+/** Discriminated state of the live drag/resize interaction. */
+export type DragState =
+ | { kind: 'idle' }
+ | { kind: 'hover-bar', issueId: Ref, edge: 'left' | 'right' | 'body' | 'none' }
+ | {
+ kind: 'dragging-body'
+ issue: Issue
+ originStart: number
+ originDue: number
+ cursorStartX: number
+ previewStart: number
+ previewDue: number
+ }
+ | {
+ kind: 'resizing-left'
+ issue: Issue
+ originStart: number
+ originDue: number
+ cursorStartX: number
+ previewStart: number
+ }
+ | {
+ kind: 'resizing-right'
+ issue: Issue
+ originStart: number
+ originDue: number
+ cursorStartX: number
+ previewDue: number
+ }
+ | {
+ kind: 'dragging-unscheduled'
+ issue: Issue
+ /** Anchor date the drag was started from (defaults to today at UTC midnight). */
+ originStart: number
+ /** originStart + 1 day; used for ghost-outline / commit symmetry with dragging-body. */
+ originDue: number
+ cursorStartX: number
+ previewStart: number
+ previewDue: number
+ /**
+ * True once the cursor has been over the canvas during the drag and a real
+ * canvas-X has been observed. Guards against the click-without-drag case
+ * where mouseup fires before the user has moved over the canvas — committing
+ * such a "drag" would schedule the issue to today silently. `commitDrag`
+ * treats `dragging-unscheduled && !hasCanvasTarget` as a no-op.
+ */
+ hasCanvasTarget: boolean
+ }
+
+/**
+ * Input events fed into the drag-controller reducer.
+ *
+ * Coordinate spaces:
+ * - `cursorX` is always window-space `MouseEvent.clientX`. It drives delta
+ * math for `dragging-body` / `resizing-left` / `resizing-right`, which only
+ * care about *how much* the cursor moved since `mousedown`.
+ * - `canvasX` (optional on `mousemove`) is the cursor's X position in the
+ * canvas's content coordinate system — i.e., already accounting for the
+ * sidebar's left offset and the horizontal scroll. `timeScale.fromX(canvasX)`
+ * yields the absolute date under the cursor. It is used by
+ * `dragging-unscheduled` to snap the dropped issue to the date the cursor
+ * is actually pointing at, not to a delta from "today".
+ *
+ * When the cursor is over the sidebar (e.g., at the start of an unscheduled
+ * drag), `canvasX` is undefined and the unscheduled preview holds its
+ * default ("today") until the cursor enters the canvas.
+ */
+export type DragEvent =
+ | { type: 'mouseenter-bar', issueId: Ref, edge: 'left' | 'right' | 'body' }
+ | { type: 'mouseleave-bar' }
+ | { type: 'mousedown-bar', issue: Issue, edge: 'left' | 'right' | 'body', cursorX: number }
+ | { type: 'mousedown-unscheduled', issue: Issue, cursorX: number }
+ | { type: 'mousemove', cursorX: number, canvasX?: number }
+ | { type: 'mouseup' }
+ | { type: 'cancel' }
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 }}
/>
+
{/if}
+ {:else if !effectiveReadonly && issue !== undefined}
+ ]
+
{/if}