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 @@ + + + + + + + {#each ticks as tick (tick.date)} + {@const x = timeScale.toX(tick.date)} + + {/each} + + + + + {#each visibleRows as row (rowKey(row))} + {@const isHover = hoveredRowId === row.id} + + {/each} + + + + {#each visibleRows as row (rowKey(row))} + + dispatch('hoverRow', { id: row.id, row, mouseX: e.clientX, mouseY: e.clientY })} + on:mousemove={(e) => dispatch('hoverRow', { id: row.id, row, mouseX: e.clientX, mouseY: e.clientY })} + on:mouseleave={() => dispatch('hoverRow', { id: null })} + > + + + {#if row.kind === 'milestone' && row.milestone !== null} + {@const range = summaryFor(row)} + {#if range !== null} + {#if range.startDate !== null && range.dueDate !== null} + + {/if} + {/if} + {:else if row.issue !== null} + + { + if (row.issue !== null) openIssue(row.issue) + }} + > + + + {/if} + + {/each} + + + + + {#each milestones as ms (ms._id)} + {@const x = timeScale.toX(ms.targetDate)} + + + {ms.label} + + {/each} + + + + + + + + + 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 @@ + + + + + + + + {#each ticks as tick (tick.date)} + {@const x = timeScale.toX(tick.date)} + + + {#if tick.secondaryLabel !== undefined} + + {tick.secondaryLabel} + + {/if} + + {tick.label} + + {/each} + + + 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} +
+
+ + + + + + +
+
+ {#each ZOOM_LEVELS as z (z)} + + {/each} +
+
+
+ + + +
+
+ +
+
+ + {#if showStatus}{/if} + {#if showIssueCode}{/if} + {#if showTitle}{/if} + +
+
+ + { + if (e.key === 'Enter') jumpToToday() + }} + role="button" + tabindex="0" + > + {formatRange(dateRange.from)} – {formatRange(dateRange.to)} + + +
+
+
+
+
+ +
+
+ + +
+
+
+ +
+
+
+
+ + {#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 row.kind === 'milestone' && ms !== null} +
+
{ms.label}
+ {#if ms.startDate !== null} +
+
+ {/if} +
+
+ {:else if issue !== null} + {@const code = issueCode(issue)} +
{code}
+
{issue.title}
+ {#if issue.startDate !== null} +
+
+ {/if} + {#if issue.dueDate !== null} +
+
+ {/if} + {#if issue.startDate !== null && issue.dueDate !== null} + {@const days = + Math.round( + (Math.max(issue.dueDate, issue.startDate) - Math.min(issue.dueDate, issue.startDate)) / 86_400_000 + ) + 1} +
+ {/if} + {/if} +
+ {/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 }} /> +
+ {:else if !effectiveReadonly && issue !== undefined} +
+
{/if}