diff --git a/.claude/rules/api-evolution.md b/.claude/rules/api-evolution.md index 8ac0d63..0ab75fc 100644 --- a/.claude/rules/api-evolution.md +++ b/.claude/rules/api-evolution.md @@ -4,13 +4,18 @@ Si une demande ne peut pas être implémentée avec les endpoints API existants (endpoint manquant, champ absent, comportement insuffisant), ne pas contourner le problème côté frontend. ## Action requise -Créer un fichier dans le dossier `api-issues/` à la racine du projet, nommé d'après le **numéro de ticket** extrait du nom de la branche courante, suivi du slug de la branche : +Créer un **nouveau fichier** dans le dossier `api-issues/` à la racine du projet pour **chaque besoin d'évolution API distinct**. Ne jamais modifier un fichier existant : toujours créer un fichier supplémentaire. + +Le nom du fichier est composé du **numéro de ticket** (extrait de la branche courante) suivi d'un **slug explicite décrivant le changement demandé** : ``` -api-issues/-.md +api-issues/-.md ``` -> **Exemple** : branche `feat/30-ordre-statut` → fichier `api-issues/30-ordre-statut.md` +> **Exemple** : ticket 30, besoin d'ajouter les dates de milestone → fichier `api-issues/30-milestone-dates.md` +> **Exemple** : ticket 30, besoin d'un endpoint de statistiques → fichier `api-issues/30-statistiques-epic.md` + +Si plusieurs besoins distincts émergent pour le même ticket, créer autant de fichiers séparés. ## Contenu du fichier Le fichier est un **prompt** destiné à un agent ou développeur backend. Il doit être rédigé comme une instruction directe et suffisamment complète pour être exécutée sans contexte supplémentaire. Il doit décrire : diff --git a/.claude/settings.json b/.claude/settings.json index 81c0137..bc0750e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,11 +4,20 @@ "Bash(mkdir -p /var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/dashboard)", "Bash(mkdir -p /var/home/Gato/IdeaProjects/Bonsai-webapp/src/app/statuses)", "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('projects',{}\\).get\\('Bonsai-webapp',{}\\).get\\('architect',{}\\).get\\('test',{}\\), indent=2\\)\\)\")", - "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\({k: d[k] for k in ['main','module','exports','type'] if k in d}, indent=2\\)\\)\")" + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\({k: d[k] for k in ['main','module','exports','type'] if k in d}, indent=2\\)\\)\")", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d['projects']['bonsai-webapp']['architect']['build']['options'], indent=2\\)\\)\")", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); keys=list\\(d['projects'].keys\\(\\)\\); print\\(keys\\)\")", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); opts=d['projects']['Bonsai-webapp']['architect']['build']['options']; print\\(json.dumps\\({k:opts[k] for k in ['styles','scripts','assets'] if k in opts}, indent=2\\)\\)\")", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('main',''\\), d.get\\('module',''\\), d.get\\('types',''\\), d.get\\('exports',''\\)\\)\")", + "Bash(grep -n \"^\\\\s*function \\\\$\\\\|const \\\\$ =\\\\|\\\\$ = \" /var/home/Gato/IdeaProjects/Bonsai-webapp/node_modules/frappe-gantt/dist/frappe-gantt.es.js)", + "Bash(grep -n \"function \\\\$\\\\b\\\\|const \\\\$ \" /var/home/Gato/IdeaProjects/Bonsai-webapp/node_modules/frappe-gantt/dist/frappe-gantt.es.js)", + "Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")" ], "additionalDirectories": [ "/var/home/Gato/IdeaProjects/Bonsai-webapp/src/app", - "/var/home/Gato/IdeaProjects/Bonsai-webapp.wiki" + "/var/home/Gato/IdeaProjects/Bonsai-webapp.wiki", + "/home/Gato/IdeaProjects/Bonsai-webapp/api-issues", + "/var/home/Gato/IdeaProjects/Bonsai-webapp/api-issues" ] } } diff --git a/angular.json b/angular.json index f5974fd..431e4d9 100644 --- a/angular.json +++ b/angular.json @@ -31,7 +31,8 @@ } ], "styles": [ - "src/styles.css" + "src/styles.css", + "node_modules/frappe-gantt/dist/frappe-gantt.css" ] }, "configurations": { diff --git a/package-lock.json b/package-lock.json index ad85746..656e5ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bonsai-webapp", - "version": "0.1.0", + "version": "0.1.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bonsai-webapp", - "version": "0.1.0", + "version": "0.1.11", "dependencies": { "@angular/common": "^21.2.0", "@angular/compiler": "^21.2.0", @@ -15,6 +15,7 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "bootstrap": "^5.3.3", + "frappe-gantt": "^1.2.2", "keycloak-js": "^26.2.4", "marked": "^18.0.4", "rxjs": "~7.8.0", @@ -5387,6 +5388,12 @@ "node": ">= 0.6" } }, + "node_modules/frappe-gantt": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/frappe-gantt/-/frappe-gantt-1.2.2.tgz", + "integrity": "sha512-1+uPNRa92LBIeKiZCVhJMiGIifJk5ONaoerXI8eBREXWRZGGoTJR5ATpMpsnAQcyBA6Gnq5wIht4eL+e4eHMBA==", + "license": "MIT" + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", diff --git a/package.json b/package.json index 870d4d6..dc6f8de 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "bootstrap": "^5.3.3", + "frappe-gantt": "^1.2.2", "keycloak-js": "^26.2.4", "marked": "^18.0.4", "rxjs": "~7.8.0", diff --git a/src/app/dashboard/dashboard.spec.ts b/src/app/dashboard/dashboard.spec.ts index 437f3a5..60bbf6d 100644 --- a/src/app/dashboard/dashboard.spec.ts +++ b/src/app/dashboard/dashboard.spec.ts @@ -12,6 +12,9 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ assignee: '', epic: '', name: 'Test Issue', + startDate: '', + startDateMode: 'forced', + endDate: '', dueDate: '', description: '', estimatedTime: null, @@ -236,7 +239,7 @@ describe('Dashboard', () => { describe('activeMilestones', () => { it('exclut les milestones terminés à 100%', () => { issuesStore.seed([makeIssue({ id: 1, status: 'done' })]); - milestonesStore.seed([{ id: 1, name: 'Done Milestone', description: '', dueDate: '', issueIds: [1] }]); + milestonesStore.seed([{ id: 1, name: 'Done Milestone', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [1], dependsOnIds: [] }]); expect((component as any).activeMilestones().length).toBe(0); }); @@ -245,7 +248,7 @@ describe('Dashboard', () => { makeIssue({ id: 1, status: 'done' }), makeIssue({ id: 2, status: 'todo' }), ]); - milestonesStore.seed([{ id: 1, name: 'Active', description: '', dueDate: '', issueIds: [1, 2] }]); + milestonesStore.seed([{ id: 1, name: 'Active', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [1, 2], dependsOnIds: [] }]); expect((component as any).activeMilestones().length).toBe(1); }); }); diff --git a/src/app/issues/issue-comments/issue-comments.spec.ts b/src/app/issues/issue-comments/issue-comments.spec.ts index 417e34c..74f3ae8 100644 --- a/src/app/issues/issue-comments/issue-comments.spec.ts +++ b/src/app/issues/issue-comments/issue-comments.spec.ts @@ -11,6 +11,9 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ assignee: '', epic: '', name: 'Test Issue', + startDate: '', + startDateMode: 'forced', + endDate: '', dueDate: '', description: '', estimatedTime: null, diff --git a/src/app/issues/issue-comments/issue-comments.ts b/src/app/issues/issue-comments/issue-comments.ts index 4b2dda4..5861332 100644 --- a/src/app/issues/issue-comments/issue-comments.ts +++ b/src/app/issues/issue-comments/issue-comments.ts @@ -170,6 +170,9 @@ export class IssueComments { name, assignee: '', epic: '', + startDate: '', + startDateMode: 'forced', + endDate: '', dueDate: '', description: '', estimatedTime: null, diff --git a/src/app/issues/issue-detail/issue-detail.css b/src/app/issues/issue-detail/issue-detail.css index 27ef477..80cba94 100644 --- a/src/app/issues/issue-detail/issue-detail.css +++ b/src/app/issues/issue-detail/issue-detail.css @@ -240,3 +240,22 @@ margin-bottom: 0; } +/* Date mode label row */ +.field-label-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.3rem; +} + +.date-mode-select { + font-size: 0.7rem; + font-weight: 600; + color: #6b7280; + border: none; + background: transparent; + cursor: pointer; + padding: 0; + appearance: auto; +} + diff --git a/src/app/issues/issue-detail/issue-detail.html b/src/app/issues/issue-detail/issue-detail.html index bea9ad4..e7cc932 100644 --- a/src/app/issues/issue-detail/issue-detail.html +++ b/src/app/issues/issue-detail/issue-detail.html @@ -153,6 +153,58 @@ +
+
+
+ Date de début + @if (hasDependencies) { + + } +
+ @if (issue.startDateMode === 'calculated') { + + @if (startDateModeWarning) { +
{{ startDateModeWarning }}
+ } + } @else { + + } +
+
+ + +
+ @if (dateValidationError) { +
{{ dateValidationError }}
+ } +
@@ -160,7 +212,11 @@
- + @if (isEpicIssue) { +
{{ epicEstimatedTime !== null ? epicEstimatedTime : '—' }}
+ } @else { + + }
@if (!isEpicIssue) {
@@ -314,6 +370,16 @@
} + +@if (isEpicIssue && !isNewIssueRoute) { +
+
Diagramme Gantt
+
+ +
+
+} + @if (!isNewIssueRoute) { diff --git a/src/app/issues/issue-detail/issue-detail.spec.ts b/src/app/issues/issue-detail/issue-detail.spec.ts index ada3755..8ab90e4 100644 --- a/src/app/issues/issue-detail/issue-detail.spec.ts +++ b/src/app/issues/issue-detail/issue-detail.spec.ts @@ -14,6 +14,9 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ assignee: '', epic: '', name: 'Test Issue', + startDate: '', + startDateMode: 'forced', + endDate: '', dueDate: '', description: '', estimatedTime: null, @@ -86,8 +89,11 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit id: 1, name: 'Sprint 1', description: '', + startDate: '', + endDate: '', dueDate: '', issueIds: [], + dependsOnIds: [], ...overrides, }); @@ -213,6 +219,253 @@ describe('IssueDetail — existing issue', () => { (component as any).saveIssue(); expect(store.issues().length).toBe(countBefore); }); + + it('does not persist when dateValidationError is set', async () => { + (component as any).issue.name = 'Has Dates'; + (component as any).issue.startDate = '2026-02-01'; + (component as any).issue.endDate = '2026-01-01'; + await (component as any).saveIssue(); + expect(store.getById(1)?.name).not.toBe('Has Dates'); + }); + }); + + describe('dateValidationError', () => { + it('returns null when both dates are empty', () => { + (component as any).issue.startDate = ''; + (component as any).issue.endDate = ''; + expect((component as any).dateValidationError).toBeNull(); + }); + + it('returns null when only startDate is set', () => { + (component as any).issue.startDate = '2026-01-01'; + (component as any).issue.endDate = ''; + expect((component as any).dateValidationError).toBeNull(); + }); + + it('returns null when only endDate is set', () => { + (component as any).issue.startDate = ''; + (component as any).issue.endDate = '2026-01-31'; + expect((component as any).dateValidationError).toBeNull(); + }); + + it('returns null when startDate equals endDate', () => { + (component as any).issue.startDate = '2026-01-15'; + (component as any).issue.endDate = '2026-01-15'; + expect((component as any).dateValidationError).toBeNull(); + }); + + it('returns null when startDate is before endDate', () => { + (component as any).issue.startDate = '2026-01-01'; + (component as any).issue.endDate = '2026-01-31'; + expect((component as any).dateValidationError).toBeNull(); + }); + + it('returns an error when startDate is after endDate', () => { + (component as any).issue.startDate = '2026-02-01'; + (component as any).issue.endDate = '2026-01-01'; + expect((component as any).dateValidationError).toContain('supérieure à la date de fin'); + }); + + it('returns null when dependency has no endDate and startDate is set', async () => { + store.upsert(makeIssue({ id: 10, startDate: '2026-01-01', endDate: '' })); + (component as any).issue.dependsOnIds = [10]; + (component as any).issue.startDate = '2025-12-01'; + expect((component as any).dateValidationError).toBeNull(); + }); + + it('returns an error when startDate is before dependency endDate (Finish-to-Start)', async () => { + store.upsert(makeIssue({ id: 10, endDate: '2026-02-01' })); + (component as any).issue.dependsOnIds = [10]; + (component as any).issue.startDate = '2026-01-15'; + expect((component as any).dateValidationError).toContain('#10'); + }); + + it('returns null when startDate equals dependency endDate', async () => { + store.upsert(makeIssue({ id: 10, endDate: '2026-02-01' })); + (component as any).issue.dependsOnIds = [10]; + (component as any).issue.startDate = '2026-02-01'; + expect((component as any).dateValidationError).toBeNull(); + }); + + it('returns null when startDate is after dependency endDate', async () => { + store.upsert(makeIssue({ id: 10, endDate: '2026-02-01' })); + (component as any).issue.dependsOnIds = [10]; + (component as any).issue.startDate = '2026-02-15'; + expect((component as any).dateValidationError).toBeNull(); + }); + + it('returns null when there are no dependencies', () => { + (component as any).issue.dependsOnIds = []; + (component as any).issue.startDate = '2026-01-01'; + expect((component as any).dateValidationError).toBeNull(); + }); + }); + + describe('hasDependencies', () => { + it('returns false when dependsOnIds is empty', () => { + (component as any).issue.dependsOnIds = []; + expect((component as any).hasDependencies).toBe(false); + }); + + it('returns true when dependsOnIds has entries', () => { + (component as any).issue.dependsOnIds = [2]; + expect((component as any).hasDependencies).toBe(true); + }); + }); + + describe('calculatedStartDate', () => { + it('returns empty string when there are no dependencies', () => { + (component as any).issue.dependsOnIds = []; + expect((component as any).calculatedStartDate).toBe(''); + }); + + it('returns empty string when dependencies have no endDate', () => { + store.upsert(makeIssue({ id: 10, endDate: '' })); + (component as any).issue.dependsOnIds = [10]; + expect((component as any).calculatedStartDate).toBe(''); + }); + + it('returns the endDate of the single dependency', () => { + store.upsert(makeIssue({ id: 10, endDate: '2026-03-01' })); + (component as any).issue.dependsOnIds = [10]; + expect((component as any).calculatedStartDate).toBe('2026-03-01'); + }); + + it('returns the latest endDate when multiple dependencies exist', () => { + store.upsert(makeIssue({ id: 10, endDate: '2026-03-01' })); + store.upsert(makeIssue({ id: 11, endDate: '2026-04-15' })); + store.upsert(makeIssue({ id: 12, endDate: '2026-02-20' })); + (component as any).issue.dependsOnIds = [10, 11, 12]; + expect((component as any).calculatedStartDate).toBe('2026-04-15'); + }); + + it('ignores dependencies without endDate in the max calculation', () => { + store.upsert(makeIssue({ id: 10, endDate: '2026-03-01' })); + store.upsert(makeIssue({ id: 11, endDate: '' })); + (component as any).issue.dependsOnIds = [10, 11]; + expect((component as any).calculatedStartDate).toBe('2026-03-01'); + }); + }); + + describe('startDateModeWarning', () => { + it('returns null in forced mode', () => { + (component as any).issue.startDateMode = 'forced'; + expect((component as any).startDateModeWarning).toBeNull(); + }); + + it('returns null in calculated mode when a dependency has an endDate', () => { + store.upsert(makeIssue({ id: 10, endDate: '2026-03-01' })); + (component as any).issue.dependsOnIds = [10]; + (component as any).issue.startDateMode = 'calculated'; + expect((component as any).startDateModeWarning).toBeNull(); + }); + + it('returns a warning message in calculated mode when no dependency has an endDate', () => { + store.upsert(makeIssue({ id: 10, endDate: '' })); + (component as any).issue.dependsOnIds = [10]; + (component as any).issue.startDateMode = 'calculated'; + expect((component as any).startDateModeWarning).toContain('date de fin'); + }); + }); + + describe('startDateModeValue setter', () => { + it('switching to calculated sets startDate from dependency endDate', () => { + store.upsert(makeIssue({ id: 10, endDate: '2026-05-01' })); + (component as any).issue.dependsOnIds = [10]; + (component as any).issue.startDateMode = 'forced'; + (component as any).startDateModeValue = 'calculated'; + expect((component as any).issue.startDate).toBe('2026-05-01'); + }); + + it('switching to calculated recalculates endDate', () => { + store.upsert(makeIssue({ id: 10, endDate: '2026-05-01' })); + (component as any).issue.dependsOnIds = [10]; + (component as any).issue.estimatedTime = 8; + (component as any).startDateModeValue = 'calculated'; + expect((component as any).issue.endDate).toBe('2026-05-01'); + }); + + it('switching to forced preserves the current startDate', () => { + (component as any).issue.startDate = '2026-04-10'; + (component as any).issue.startDateMode = 'calculated'; + (component as any).startDateModeValue = 'forced'; + expect((component as any).issue.startDate).toBe('2026-04-10'); + }); + }); + + describe('dateValidationError — calculated mode', () => { + it('does not flag dependency constraint in calculated mode', () => { + store.upsert(makeIssue({ id: 10, endDate: '2026-02-01' })); + (component as any).issue.dependsOnIds = [10]; + (component as any).issue.startDate = '2026-01-01'; + (component as any).issue.startDateMode = 'calculated'; + expect((component as any).dateValidationError).toBeNull(); + }); + + it('still flags startDate > endDate in calculated mode', () => { + (component as any).issue.startDate = '2026-02-01'; + (component as any).issue.endDate = '2026-01-01'; + (component as any).issue.startDateMode = 'calculated'; + expect((component as any).dateValidationError).toContain('supérieure à la date de fin'); + }); + }); + + describe('recalculateEndDate (via estimatedTimeValue setter)', () => { + it('sets endDate to startDate when estimatedTime is 8h or less', () => { + (component as any).issue.startDate = '2026-06-01'; + (component as any).estimatedTimeValue = 8; + expect((component as any).issue.endDate).toBe('2026-06-01'); + }); + + it('adds one day for estimatedTime between 9h and 16h', () => { + (component as any).issue.startDate = '2026-06-01'; + (component as any).estimatedTimeValue = 16; + expect((component as any).issue.endDate).toBe('2026-06-02'); + }); + + it('adds two days for estimatedTime between 17h and 24h', () => { + (component as any).issue.startDate = '2026-06-01'; + (component as any).estimatedTimeValue = 24; + expect((component as any).issue.endDate).toBe('2026-06-03'); + }); + + it('clears endDate when startDate is empty', () => { + (component as any).issue.startDate = ''; + (component as any).issue.endDate = '2026-06-05'; + (component as any).estimatedTimeValue = 8; + expect((component as any).issue.endDate).toBe(''); + }); + + it('clears endDate when estimatedTime is null', () => { + (component as any).issue.startDate = '2026-06-01'; + (component as any).issue.endDate = '2026-06-05'; + (component as any).estimatedTimeValue = null; + expect((component as any).issue.endDate).toBe(''); + }); + + it('clears endDate when estimatedTime is 0', () => { + (component as any).issue.startDate = '2026-06-01'; + (component as any).issue.endDate = '2026-06-05'; + (component as any).estimatedTimeValue = 0; + expect((component as any).issue.endDate).toBe(''); + }); + }); + + describe('onStartDateBlur', () => { + it('recalculates endDate when startDate changes', () => { + (component as any).issue.estimatedTime = 16; + (component as any).issue.startDate = '2026-06-10'; + (component as any).onStartDateBlur(); + expect((component as any).issue.endDate).toBe('2026-06-11'); + }); + + it('clears endDate when startDate is cleared', () => { + (component as any).issue.estimatedTime = 16; + (component as any).issue.endDate = '2026-06-11'; + (component as any).issue.startDate = ''; + (component as any).onStartDateBlur(); + expect((component as any).issue.endDate).toBe(''); + }); }); describe('deleteIssue', () => { @@ -438,6 +691,39 @@ describe('IssueDetail — existing issue', () => { }); }); + describe('epicEstimatedTime', () => { + beforeEach(() => { + (component as any).issue.type = 'Epic'; + (component as any).issue.name = 'Test Epic'; + }); + + it('returns null when there are no child issues', () => { + expect((component as any).epicEstimatedTime).toBeNull(); + }); + + it('returns null when all children have null estimatedTime', () => { + store.upsert(makeIssue({ id: 200, epic: 'Test Epic', estimatedTime: null })); + expect((component as any).epicEstimatedTime).toBeNull(); + }); + + it('returns the sum of children estimatedTime', () => { + store.upsert(makeIssue({ id: 200, epic: 'Test Epic', estimatedTime: 8 })); + store.upsert(makeIssue({ id: 201, epic: 'Test Epic', estimatedTime: 4 })); + expect((component as any).epicEstimatedTime).toBe(12); + }); + + it('ignores children with null estimatedTime in the sum', () => { + store.upsert(makeIssue({ id: 200, epic: 'Test Epic', estimatedTime: 8 })); + store.upsert(makeIssue({ id: 201, epic: 'Test Epic', estimatedTime: null })); + expect((component as any).epicEstimatedTime).toBe(8); + }); + + it('includes children linked via dependsOnIds', () => { + store.upsert(makeIssue({ id: 200, dependsOnIds: [1], estimatedTime: 6 })); + expect((component as any).epicEstimatedTime).toBe(6); + }); + }); + describe('create-in-epic flow', () => { beforeEach(() => { (component as any).issue.type = 'Epic'; diff --git a/src/app/issues/issue-detail/issue-detail.ts b/src/app/issues/issue-detail/issue-detail.ts index 39cb3a9..f4cdcca 100644 --- a/src/app/issues/issue-detail/issue-detail.ts +++ b/src/app/issues/issue-detail/issue-detail.ts @@ -1,4 +1,4 @@ -import { Component, inject } from '@angular/core'; +import { Component, effect, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; @@ -9,10 +9,11 @@ import { IssueComments } from '../issue-comments/issue-comments'; import { handleImagePaste, insertAtSelection } from '../paste-image.util'; import { MilestoneEntity, MilestonesStore } from '../../milestones/milestones.store'; import { StatusEntity, StatusesStore } from '../../statuses/statuses.store'; +import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram'; @Component({ selector: 'app-issue-detail', - imports: [FormsModule, IssueComments], + imports: [FormsModule, IssueComments, GanttDiagram], templateUrl: './issue-detail.html', styleUrl: './issue-detail.css', }) @@ -55,6 +56,18 @@ export class IssueDetail { this.showCreateInEpic = false; } }); + + effect(() => { + void this.issues(); + if (this.issue.startDateMode === 'calculated') { + const newStart = this.calculatedStartDate; + if (this.issue.startDate !== newStart) { + this.issue.startDate = newStart; + this.recalculateEndDate(); + this.saveIssue(); + } + } + }); } protected showAddDependency = false; @@ -77,6 +90,10 @@ export class IssueDetail { 'Technical Story', ]; + protected get hasDependencies(): boolean { + return this.issue.dependsOnIds.length > 0; + } + protected get dependencyIds(): number[] { return this.issue.dependsOnIds; } @@ -115,12 +132,59 @@ export class IssueDetail { await this.saveIssue(); } + protected get calculatedStartDate(): string { + const dates = this.issue.dependsOnIds + .map((id) => this.issuesStore.getById(id)?.endDate) + .filter((d): d is string => !!d); + if (dates.length === 0) return ''; + return dates.reduce((max, d) => (d > max ? d : max)); + } + + protected get startDateModeWarning(): string | null { + if (this.issue.startDateMode !== 'calculated') return null; + if (!this.calculatedStartDate) return "Aucune dépendance n'a de date de fin définie."; + return null; + } + + protected get startDateModeValue(): 'forced' | 'calculated' { + return this.issue.startDateMode; + } + + protected set startDateModeValue(mode: 'forced' | 'calculated') { + this.issue.startDateMode = mode; + if (mode === 'calculated') { + this.issue.startDate = this.calculatedStartDate; + } + this.recalculateEndDate(); + this.saveIssue(); + } + protected get estimatedTimeValue(): number | null { return this.issue.estimatedTime; } protected set estimatedTimeValue(value: number | null) { this.issue.estimatedTime = value === null || value === undefined ? null : Number(value); + this.recalculateEndDate(); + } + + private recalculateEndDate(): void { + const effectiveStart = + this.issue.startDateMode === 'calculated' ? this.calculatedStartDate : this.issue.startDate; + const { estimatedTime } = this.issue; + if (!effectiveStart || estimatedTime === null || estimatedTime <= 0) { + this.issue.endDate = ''; + return; + } + const start = new Date(effectiveStart); + const extraDays = Math.max(0, Math.ceil(estimatedTime / 8) - 1); + start.setDate(start.getDate() + extraDays); + this.issue.endDate = start.toISOString().split('T')[0]; + } + + protected onStartDateBlur(): void { + this.recalculateEndDate(); + this.saveIssue(); } protected get issueTypeValue(): IssueEntity['type'] { @@ -131,6 +195,13 @@ export class IssueDetail { this.issue.type = value; } + protected get epicEstimatedTime(): number | null { + const times = this.composedIssues + .filter((i): i is IssueEntity & { estimatedTime: number } => i.estimatedTime !== null) + .map((i) => i.estimatedTime); + return times.length === 0 ? null : times.reduce((a, b) => a + b, 0); + } + protected get epicIssues(): IssueEntity[] { return this.issues().filter((issue) => issue.type === 'Epic'); } @@ -170,6 +241,9 @@ export class IssueDetail { assignee: '', epic: this.issue.name, name, + startDate: '', + startDateMode: 'calculated', + endDate: '', dueDate: '', description: '', estimatedTime: null, @@ -231,10 +305,72 @@ export class IssueDetail { return this.issueTypeValue === 'Epic'; } + protected get epicGanttTasks(): GanttTask[] { + const tasks: GanttTask[] = []; + + if (this.issue.startDate && this.issue.endDate) { + tasks.push({ + id: `issue-${this.issue.id}`, + name: this.issue.name || 'Epic', + start: this.issue.startDate, + end: this.issue.endDate, + progress: this.composedIssues.length === 0 + ? this.issue.progress + : Math.round( + (this.composedIssues.filter((i) => i.status === 'done').length / + this.composedIssues.length) * 100, + ), + custom_class: 'bar-epic', + }); + } + + const taskIds = new Set(tasks.map((t) => t.id)); + const sortedChildren = [...this.composedIssues].sort((a, b) => { + if (!a.startDate && !b.startDate) return 0; + if (!a.startDate) return 1; + if (!b.startDate) return -1; + return a.startDate.localeCompare(b.startDate); + }); + for (const child of sortedChildren) { + if (!child.startDate || !child.endDate) continue; + const deps = child.dependsOnIds + .map((id) => `issue-${id}`) + .filter((id) => taskIds.has(id)) + .join(','); + tasks.push({ + id: `issue-${child.id}`, + name: `#${child.id} ${child.name}`, + start: child.startDate, + end: child.endDate, + progress: child.progress, + ...(deps ? { dependencies: deps } : {}), + }); + taskIds.add(`issue-${child.id}`); + } + + return tasks; + } + protected get isChildOfEpic(): boolean { return !!this.issue.epic; } + protected get dateValidationError(): string | null { + const { startDate, endDate } = this.issue; + if (startDate && endDate && startDate > endDate) { + return 'La date de début ne peut pas être supérieure à la date de fin.'; + } + if (this.issue.startDateMode !== 'calculated' && startDate && this.issue.dependsOnIds.length > 0) { + for (const depId of this.issue.dependsOnIds) { + const dep = this.issuesStore.getById(depId); + if (dep?.endDate && startDate < dep.endDate) { + return `La date de début ne peut pas être antérieure à la date de fin de la dépendance #${depId}.`; + } + } + } + return null; + } + protected startEditDescription(): void { this._descriptionBeforeEdit = this.issue.description; this.editingDescription = true; @@ -358,6 +494,7 @@ export class IssueDetail { protected async saveIssue(explicit = false): Promise { if (this.isNewIssueRoute && !explicit) return; if (!this.issue.name.trim()) return; + if (this.dateValidationError) return; const saved = await this.issuesStore.upsert(this.issue); this.issue = { ...saved }; if (this.isNewIssueRoute) { @@ -413,6 +550,9 @@ export class IssueDetail { assignee: '', epic: '', name: '', + startDate: '', + startDateMode: 'calculated', + endDate: '', dueDate: '', description: '', estimatedTime: null, @@ -434,6 +574,9 @@ export class IssueDetail { assignee: '', epic: '', name: '', + startDate: '', + startDateMode: 'calculated', + endDate: '', dueDate: '', description: '', estimatedTime: null, diff --git a/src/app/issues/issues.spec.ts b/src/app/issues/issues.spec.ts index 609aba3..4fb956e 100644 --- a/src/app/issues/issues.spec.ts +++ b/src/app/issues/issues.spec.ts @@ -13,6 +13,9 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ assignee: '', epic: '', name: 'Test Issue', + startDate: '', + startDateMode: 'forced', + endDate: '', dueDate: '', description: '', estimatedTime: null, @@ -93,8 +96,11 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit id: 1, name: 'Sprint 1', description: '', + startDate: '', + endDate: '', dueDate: '', issueIds: [], + dependsOnIds: [], ...overrides, }); diff --git a/src/app/issues/issues.store.spec.ts b/src/app/issues/issues.store.spec.ts index a176185..ba08360 100644 --- a/src/app/issues/issues.store.spec.ts +++ b/src/app/issues/issues.store.spec.ts @@ -10,6 +10,9 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ assignee: '', epic: '', name: 'Test Issue', + startDate: '', + startDateMode: 'forced', + endDate: '', dueDate: '', description: '', estimatedTime: null, @@ -156,6 +159,148 @@ describe('IssuesStore', () => { await p; expect(store.getById(994)?.estimatedTime).toBeNull(); }); + + it('defaults startDate to empty string when missing in API response', async () => { + const issue = { ...makeIssue({ id: 0 }), startDate: undefined } as any; + const p = store.upsert(issue); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush({ ...makeIssue({ id: 993 }), startDate: undefined }); + await p; + expect(store.getById(993)?.startDate).toBe(''); + }); + + it('defaults endDate to empty string when missing in API response', async () => { + const issue = { ...makeIssue({ id: 0 }), endDate: undefined } as any; + const p = store.upsert(issue); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush({ ...makeIssue({ id: 992 }), endDate: undefined }); + await p; + expect(store.getById(992)?.endDate).toBe(''); + }); + + it('preserves startDate and endDate when provided', async () => { + const issue = makeIssue({ id: 0, startDate: '2026-01-01', endDate: '2026-01-31' }); + const p = store.upsert(issue); + httpMock.expectOne({ method: 'POST', url: `${API_BASE_URL}/issues` }).flush(makeIssue({ id: 991, startDate: '2026-01-01', endDate: '2026-01-31' })); + await p; + expect(store.getById(991)?.startDate).toBe('2026-01-01'); + expect(store.getById(991)?.endDate).toBe('2026-01-31'); + }); + + it('restores startDateMode when the API response omits it', async () => { + const issue = makeIssue({ id: 1, startDateMode: 'calculated' }); + const p = store.upsert(issue); + const apiResponse = { ...makeIssue({ id: 1 }), startDateMode: undefined }; + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(apiResponse); + await p; + expect(store.getById(1)?.startDateMode).toBe('calculated'); + }); + + it('restores linkedIssueIds in comments when API response omits them', async () => { + const issueWithComment = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: [2, 3] }] }); + const apiResponse = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: undefined as any }] }); + const p = store.upsert(issueWithComment); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(apiResponse); + await p; + expect(store.getById(1)?.comments[0].linkedIssueIds).toEqual([2, 3]); + }); + + it('keeps existing linkedIssueIds in comments when already present in API response', async () => { + const issue = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: [5] }] }); + const apiResponse = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: [5] }] }); + const p = store.upsert(issue); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(apiResponse); + await p; + expect(store.getById(1)?.comments[0].linkedIssueIds).toEqual([5]); + }); + }); + + describe('cascade recalculation of calculated-mode issues', () => { + it('updates startDate of a calculated-mode dependent when its dependency endDate changes', async () => { + await loadWith([ + makeIssue({ id: 1, endDate: '2026-06-01' }), + makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-01', endDate: '' }), + ]); + const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' })); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' })); + await p; + expect(store.getById(2)?.startDate).toBe('2026-06-10'); + }); + + it('recalculates endDate of a calculated-mode dependent based on estimatedTime', async () => { + await loadWith([ + makeIssue({ id: 1, endDate: '2026-06-01' }), + makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-01', estimatedTime: 16, endDate: '2026-06-02' }), + ]); + const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' })); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' })); + await p; + expect(store.getById(2)?.startDate).toBe('2026-06-10'); + expect(store.getById(2)?.endDate).toBe('2026-06-11'); + }); + + it('clears endDate when dependency loses its endDate', async () => { + await loadWith([ + makeIssue({ id: 1, endDate: '2026-06-10' }), + makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-10', estimatedTime: 8, endDate: '2026-06-10' }), + ]); + const p = store.upsert(makeIssue({ id: 1, endDate: '' })); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '' })); + await p; + expect(store.getById(2)?.startDate).toBe(''); + expect(store.getById(2)?.endDate).toBe(''); + }); + + it('cascades through a chain A → B → C', async () => { + await loadWith([ + makeIssue({ id: 1, endDate: '2026-06-01' }), + makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-01', estimatedTime: 8, endDate: '2026-06-01' }), + makeIssue({ id: 3, startDateMode: 'calculated', dependsOnIds: [2], startDate: '2026-06-01', estimatedTime: 16, endDate: '2026-06-02' }), + ]); + const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' })); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' })); + await p; + expect(store.getById(2)?.startDate).toBe('2026-06-10'); + expect(store.getById(2)?.endDate).toBe('2026-06-10'); + expect(store.getById(3)?.startDate).toBe('2026-06-10'); + expect(store.getById(3)?.endDate).toBe('2026-06-11'); + }); + + it('does not affect forced-mode issues', async () => { + await loadWith([ + makeIssue({ id: 1, endDate: '2026-06-01' }), + makeIssue({ id: 2, startDateMode: 'forced', dependsOnIds: [1], startDate: '2026-05-01', endDate: '2026-05-15' }), + ]); + const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' })); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' })); + await p; + expect(store.getById(2)?.startDate).toBe('2026-05-01'); + expect(store.getById(2)?.endDate).toBe('2026-05-15'); + }); + + it('uses the latest endDate among multiple dependencies', async () => { + await loadWith([ + makeIssue({ id: 1, endDate: '2026-06-01' }), + makeIssue({ id: 2, endDate: '2026-06-05' }), + makeIssue({ id: 3, startDateMode: 'calculated', dependsOnIds: [1, 2], startDate: '', estimatedTime: 8, endDate: '' }), + ]); + const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' })); + httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' })); + await p; + expect(store.getById(3)?.startDate).toBe('2026-06-10'); + expect(store.getById(3)?.endDate).toBe('2026-06-10'); + }); + + it('recalculates after deleteById removes a dependency', async () => { + await loadWith([ + makeIssue({ id: 1, endDate: '2026-06-10' }), + makeIssue({ id: 2, endDate: '2026-06-05' }), + makeIssue({ id: 3, startDateMode: 'calculated', dependsOnIds: [1, 2], startDate: '2026-06-10', estimatedTime: 8, endDate: '2026-06-10' }), + ]); + const p = store.deleteById(1); + httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null); + await p; + expect(store.getById(3)?.startDate).toBe('2026-06-05'); + expect(store.getById(3)?.endDate).toBe('2026-06-05'); + }); }); describe('deleteById', () => { diff --git a/src/app/issues/issues.store.ts b/src/app/issues/issues.store.ts index 2af2a26..31367c1 100644 --- a/src/app/issues/issues.store.ts +++ b/src/app/issues/issues.store.ts @@ -20,6 +20,9 @@ export type IssueEntity = { assignee: string; epic: string; name: string; + startDate: string; + startDateMode: 'forced' | 'calculated'; + endDate: string; dueDate: string; description: string; estimatedTime: number | null; @@ -66,6 +69,7 @@ export class IssuesStore { const { id: _id, ...body } = normalized; const created = this.normalizeIssue(await firstValueFrom(this.api.create(body))); this.data.update((issues) => [...issues, created]); + this.recalculateCalculatedIssues(); return created; } else { const apiResult = await firstValueFrom(this.api.update(normalized.id, normalized)); @@ -78,6 +82,10 @@ export class IssuesStore { return { ...c, linkedIssueIds: sent?.linkedIssueIds ?? [] }; }); } + // L'API ne retourne pas startDateMode : on le restaure depuis les données envoyées. + if (apiResult.startDateMode == null) { + apiResult.startDateMode = normalized.startDateMode; + } const updated = this.normalizeIssue(apiResult); this.data.update((issues) => { const idx = issues.findIndex((i) => i.id === normalized.id); @@ -86,6 +94,7 @@ export class IssuesStore { copy[idx] = updated; return copy; }); + this.recalculateCalculatedIssues(); return updated; } } @@ -97,6 +106,41 @@ export class IssuesStore { .filter((i) => i.id !== id) .map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })), ); + this.recalculateCalculatedIssues(); + } + + private recalculateCalculatedIssues(): void { + let anyChanged: boolean; + do { + anyChanged = false; + this.data.update((issues) => { + const result = issues.map((issue) => { + if (issue.startDateMode !== 'calculated') return issue; + const newStart = this.computeStartDate(issue, issues); + const newEnd = this.computeEndDate(newStart, issue.estimatedTime); + if (issue.startDate === newStart && issue.endDate === newEnd) return issue; + anyChanged = true; + return { ...issue, startDate: newStart, endDate: newEnd }; + }); + return anyChanged ? result : issues; + }); + } while (anyChanged); + } + + private computeStartDate(issue: IssueEntity, allIssues: IssueEntity[]): string { + const dates = issue.dependsOnIds + .map((id) => allIssues.find((i) => i.id === id)?.endDate) + .filter((d): d is string => !!d); + if (dates.length === 0) return ''; + return dates.reduce((max, d) => (d > max ? d : max)); + } + + private computeEndDate(startDate: string, estimatedTime: number | null): string { + if (!startDate || estimatedTime === null || estimatedTime <= 0) return ''; + const start = new Date(startDate); + const extraDays = Math.max(0, Math.ceil(estimatedTime / 8) - 1); + start.setDate(start.getDate() + extraDays); + return start.toISOString().split('T')[0]; } private normalizeIssue( @@ -110,6 +154,9 @@ export class IssuesStore { return { ...issue, type: issue.type ?? 'Story', + startDate: issue.startDate ?? '', + startDateMode: issue.startDateMode === 'calculated' ? 'calculated' : 'forced', + endDate: issue.endDate ?? '', estimatedTime: issue.estimatedTime ?? null, dependsOnIds: normalizedDependencies, comments: Array.isArray(issue.comments) diff --git a/src/app/milestones/milestone-detail/milestone-detail.css b/src/app/milestones/milestone-detail/milestone-detail.css index 64f5182..fb31a7d 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.css +++ b/src/app/milestones/milestone-detail/milestone-detail.css @@ -98,6 +98,24 @@ background: #f3f4f6; } +.dep-badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.6rem; + background: #f3f4f6; + border: 1px solid #e5e7eb; + border-radius: 999px; + font-size: 0.85rem; + color: #374151; +} + +.dep-id { + font-weight: 700; + color: #6b7280; + font-size: 0.78rem; +} + .dep-remove { border: none; background: transparent; diff --git a/src/app/milestones/milestone-detail/milestone-detail.html b/src/app/milestones/milestone-detail/milestone-detail.html index 901ac47..011485d 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.html +++ b/src/app/milestones/milestone-detail/milestone-detail.html @@ -47,6 +47,26 @@
Informations
+
+ + +
+
+ + +
{{ progress }}%
+
+ +
{{ totalEstimatedTime !== null ? totalEstimatedTime : '—' }}
+
}
@@ -108,6 +132,40 @@
+ +@if (!isNewRoute) { +
+
Dépendances
+
+ @if (hasDependencies) { +
+ @for (depId of dependencyIds; track depId) { + + #{{ depId }} + {{ resolveDependency(depId)?.name || 'Sans nom' }} + + + } +
+ } + @if (showAddDependency) { +
+ + + +
+ } @else { + + } +
+
+} +
@@ -237,6 +295,16 @@
+ +@if (!isNewRoute) { +
+
Diagramme Gantt
+
+ +
+
+} + @if (isNewRoute) {
diff --git a/src/app/milestones/milestone-detail/milestone-detail.spec.ts b/src/app/milestones/milestone-detail/milestone-detail.spec.ts index 07d53b5..de8d458 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.spec.ts +++ b/src/app/milestones/milestone-detail/milestone-detail.spec.ts @@ -14,6 +14,9 @@ const makeIssue = (overrides: Partial = {}): IssueEntity => ({ assignee: '', epic: '', name: 'Test Issue', + startDate: '', + startDateMode: 'forced', + endDate: '', dueDate: '', description: '', estimatedTime: null, @@ -29,8 +32,11 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit id: 1, name: 'Sprint 1', description: '', + startDate: '', + endDate: '', dueDate: '', issueIds: [], + dependsOnIds: [], ...overrides, }); @@ -273,6 +279,46 @@ describe('MilestoneDetail', () => { }); }); + describe('milestoneGanttTasks', () => { + it('returns empty array when no linked issues', () => { + issuesStore.seed([]); + (component as any).milestone.issueIds = []; + expect((component as any).milestoneGanttTasks).toHaveLength(0); + }); + + it('excludes issues missing startDate or endDate', () => { + issuesStore.seed([ + makeIssue({ id: 1, startDate: '2025-01-01', endDate: '' }), + makeIssue({ id: 2, startDate: '', endDate: '2025-01-31' }), + ]); + (component as any).milestone.issueIds = [1, 2]; + expect((component as any).milestoneGanttTasks).toHaveLength(0); + }); + + it('returns a task for each issue with both dates', () => { + issuesStore.seed([ + makeIssue({ id: 1, name: 'Task A', startDate: '2025-01-01', endDate: '2025-01-15', progress: 50 }), + makeIssue({ id: 2, name: 'Task B', startDate: '2025-01-10', endDate: '2025-01-31', progress: 0 }), + ]); + (component as any).milestone.issueIds = [1, 2]; + const tasks = (component as any).milestoneGanttTasks; + expect(tasks).toHaveLength(2); + expect(tasks[0]).toMatchObject({ id: 'issue-1', name: '#1 Task A', start: '2025-01-01', end: '2025-01-15', progress: 50 }); + expect(tasks[1]).toMatchObject({ id: 'issue-2', name: '#2 Task B', start: '2025-01-10', end: '2025-01-31', progress: 0 }); + }); + + it('only includes issues linked to the milestone', () => { + issuesStore.seed([ + makeIssue({ id: 1, startDate: '2025-01-01', endDate: '2025-01-31' }), + makeIssue({ id: 2, startDate: '2025-02-01', endDate: '2025-02-28' }), + ]); + (component as any).milestone.issueIds = [1]; + const tasks = (component as any).milestoneGanttTasks; + expect(tasks).toHaveLength(1); + expect(tasks[0].id).toBe('issue-1'); + }); + }); + describe('issueSuggestions', () => { beforeEach(() => { issuesStore.seed([ @@ -456,6 +502,87 @@ describe('MilestoneDetail', () => { }); }); + describe('dependencies', () => { + beforeEach(() => { + milestonesStore.seed([ + makeMilestone({ id: 1, name: 'Sprint 1', dependsOnIds: [] }), + makeMilestone({ id: 2, name: 'Sprint 2', dependsOnIds: [] }), + makeMilestone({ id: 3, name: 'Sprint 3', dependsOnIds: [] }), + ]); + (component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', dependsOnIds: [] }); + }); + + it('hasDependencies returns false when dependsOnIds is empty', () => { + expect((component as any).hasDependencies).toBe(false); + }); + + it('hasDependencies returns true when dependsOnIds is non-empty', () => { + (component as any).milestone.dependsOnIds = [2]; + expect((component as any).hasDependencies).toBe(true); + }); + + it('dependencyIds returns the dependsOnIds array', () => { + (component as any).milestone.dependsOnIds = [2, 3]; + expect((component as any).dependencyIds).toEqual([2, 3]); + }); + + it('availableCandidates excludes the current milestone', () => { + const candidates: MilestoneEntity[] = (component as any).availableCandidates; + expect(candidates.some((m: MilestoneEntity) => m.id === 1)).toBe(false); + }); + + it('availableCandidates excludes already-added dependencies', () => { + (component as any).milestone.dependsOnIds = [2]; + const candidates: MilestoneEntity[] = (component as any).availableCandidates; + expect(candidates.some((m: MilestoneEntity) => m.id === 2)).toBe(false); + expect(candidates.some((m: MilestoneEntity) => m.id === 3)).toBe(true); + }); + + it('resolveDependency returns the milestone with the given id', () => { + expect((component as any).resolveDependency(2)?.name).toBe('Sprint 2'); + }); + + it('resolveDependency returns undefined for unknown id', () => { + expect((component as any).resolveDependency(999)).toBeUndefined(); + }); + + it('openAddDependency shows the form and resets selection', () => { + (component as any).selectedCandidateMilestoneId = 2; + (component as any).openAddDependency(); + expect((component as any).showAddDependency).toBe(true); + expect((component as any).selectedCandidateMilestoneId).toBeNull(); + }); + + it('cancelAddDependency hides the form and resets selection', () => { + (component as any).showAddDependency = true; + (component as any).selectedCandidateMilestoneId = 2; + (component as any).cancelAddDependency(); + expect((component as any).showAddDependency).toBe(false); + expect((component as any).selectedCandidateMilestoneId).toBeNull(); + }); + + it('confirmAddDependency does nothing when no candidate is selected', async () => { + (component as any).selectedCandidateMilestoneId = null; + await (component as any).confirmAddDependency(); + expect((component as any).milestone.dependsOnIds).toHaveLength(0); + }); + + it('confirmAddDependency adds the id and saves', async () => { + (component as any).selectedCandidateMilestoneId = 2; + await (component as any).confirmAddDependency(); + expect((component as any).milestone.dependsOnIds).toContain(2); + expect((component as any).showAddDependency).toBe(false); + expect((component as any).selectedCandidateMilestoneId).toBeNull(); + }); + + it('removeDependency removes the id and saves', async () => { + (component as any).milestone.dependsOnIds = [2, 3]; + await (component as any).removeDependency(2); + expect((component as any).milestone.dependsOnIds).not.toContain(2); + expect((component as any).milestone.dependsOnIds).toContain(3); + }); + }); + describe('deleteMilestone', () => { it('removes the milestone and navigates to /milestones', async () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); @@ -536,6 +663,69 @@ describe('MilestoneDetail', () => { expect((component as any).milestone.description).toContain('![image]'); }); }); + + describe('totalEstimatedTime', () => { + it('returns null when no linked issues', () => { + issuesStore.seed([]); + (component as any).milestone.issueIds = []; + expect((component as any).totalEstimatedTime).toBeNull(); + }); + + it('returns null when all linked issues have null estimatedTime', () => { + issuesStore.seed([ + makeIssue({ id: 1, estimatedTime: null }), + makeIssue({ id: 2, estimatedTime: null }), + ]); + (component as any).milestone.issueIds = [1, 2]; + expect((component as any).totalEstimatedTime).toBeNull(); + }); + + it('returns the sum of estimatedTime for non-Epic issues', () => { + issuesStore.seed([ + makeIssue({ id: 1, estimatedTime: 8 }), + makeIssue({ id: 2, estimatedTime: 4 }), + ]); + (component as any).milestone.issueIds = [1, 2]; + expect((component as any).totalEstimatedTime).toBe(12); + }); + + it('ignores null estimatedTime in the sum', () => { + issuesStore.seed([ + makeIssue({ id: 1, estimatedTime: 8 }), + makeIssue({ id: 2, estimatedTime: null }), + ]); + (component as any).milestone.issueIds = [1, 2]; + expect((component as any).totalEstimatedTime).toBe(8); + }); + + it('uses the Epic own estimatedTime, not its children', () => { + issuesStore.seed([ + makeIssue({ id: 1, type: 'Epic', name: 'My Epic', estimatedTime: 10 }), + makeIssue({ id: 2, epic: 'My Epic', estimatedTime: 5 }), + makeIssue({ id: 3, epic: 'My Epic', estimatedTime: 3 }), + ]); + (component as any).milestone.issueIds = [1]; + expect((component as any).totalEstimatedTime).toBe(10); + }); + + it('returns null for an Epic with null estimatedTime', () => { + issuesStore.seed([ + makeIssue({ id: 1, type: 'Epic', name: 'My Epic', estimatedTime: null }), + makeIssue({ id: 2, epic: 'My Epic', estimatedTime: 5 }), + ]); + (component as any).milestone.issueIds = [1]; + expect((component as any).totalEstimatedTime).toBeNull(); + }); + + it('mixes Epics and plain issues correctly', () => { + issuesStore.seed([ + makeIssue({ id: 1, type: 'Epic', name: 'My Epic', estimatedTime: 8 }), + makeIssue({ id: 3, type: 'Story', estimatedTime: 6 }), + ]); + (component as any).milestone.issueIds = [1, 3]; + expect((component as any).totalEstimatedTime).toBe(14); + }); + }); }); describe('MilestoneDetail — new route', () => { diff --git a/src/app/milestones/milestone-detail/milestone-detail.ts b/src/app/milestones/milestone-detail/milestone-detail.ts index bbdadf5..39e98f5 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.ts +++ b/src/app/milestones/milestone-detail/milestone-detail.ts @@ -8,10 +8,11 @@ import { IssueEntity, IssuesStore } from '../../issues/issues.store'; import { handleImagePaste, insertAtSelection } from '../../issues/paste-image.util'; import { MilestoneEntity, MilestonesStore } from '../milestones.store'; import { StatusEntity, StatusesStore } from '../../settings/statuses/statuses.store'; +import { GanttDiagram, GanttTask } from '../../shared/gantt-diagram/gantt-diagram'; @Component({ selector: 'app-milestone-detail', - imports: [FormsModule], + imports: [FormsModule, GanttDiagram], templateUrl: './milestone-detail.html', styleUrl: './milestone-detail.css', }) @@ -34,6 +35,8 @@ export class MilestoneDetail { protected issueSearchQuery = ''; protected showIssueSuggestions = false; protected moreMenuOpen = false; + protected showAddDependency = false; + protected selectedCandidateMilestoneId: number | null = null; constructor() { this.milestonesStore.load().then(() => { @@ -59,6 +62,47 @@ export class MilestoneDetail { }); } + protected get hasDependencies(): boolean { + return this.milestone.dependsOnIds.length > 0; + } + + protected get dependencyIds(): number[] { + return this.milestone.dependsOnIds; + } + + protected get availableCandidates(): MilestoneEntity[] { + return this.milestonesStore.milestones().filter( + (m) => m.id !== this.milestone.id && !this.milestone.dependsOnIds.includes(m.id), + ); + } + + protected resolveDependency(id: number): MilestoneEntity | undefined { + return this.milestonesStore.getById(id); + } + + protected openAddDependency(): void { + this.selectedCandidateMilestoneId = null; + this.showAddDependency = true; + } + + protected cancelAddDependency(): void { + this.showAddDependency = false; + this.selectedCandidateMilestoneId = null; + } + + protected async confirmAddDependency(): Promise { + if (this.selectedCandidateMilestoneId === null) return; + this.milestone.dependsOnIds = [...this.milestone.dependsOnIds, this.selectedCandidateMilestoneId]; + this.selectedCandidateMilestoneId = null; + this.showAddDependency = false; + await this.saveMilestone(); + } + + protected async removeDependency(id: number): Promise { + this.milestone.dependsOnIds = this.milestone.dependsOnIds.filter((depId) => depId !== id); + await this.saveMilestone(); + } + protected get linkedIssues(): IssueEntity[] { return this.issues().filter((i) => this.milestone.issueIds.includes(i.id)); } @@ -94,6 +138,41 @@ export class MilestoneDetail { return this.sanitizer.bypassSecurityTrustHtml(html); } + protected get milestoneGanttTasks(): GanttTask[] { + const tasks: GanttTask[] = []; + const taskIds = new Set(); + const sorted = [...this.linkedIssues].sort((a, b) => { + if (!a.startDate && !b.startDate) return 0; + if (!a.startDate) return 1; + if (!b.startDate) return -1; + return a.startDate.localeCompare(b.startDate); + }); + for (const issue of sorted) { + if (!issue.startDate || !issue.endDate) continue; + const deps = issue.dependsOnIds + .map((id) => `issue-${id}`) + .filter((id) => taskIds.has(id)) + .join(','); + tasks.push({ + id: `issue-${issue.id}`, + name: `#${issue.id} ${issue.name}`, + start: issue.startDate, + end: issue.endDate, + progress: issue.progress, + ...(deps ? { dependencies: deps } : {}), + }); + taskIds.add(`issue-${issue.id}`); + } + return tasks; + } + + protected get totalEstimatedTime(): number | null { + const times = this.linkedIssues + .filter((i): i is IssueEntity & { estimatedTime: number } => i.estimatedTime !== null) + .map((i) => i.estimatedTime); + return times.length === 0 ? null : times.reduce((a, b) => a + b, 0); + } + protected get progress(): number { if (this.linkedIssues.length === 0) return 0; return Math.round( @@ -141,6 +220,9 @@ export class MilestoneDetail { assignee: '', epic: '', name, + startDate: '', + startDateMode: 'forced', + endDate: '', dueDate: '', description: '', estimatedTime: null, @@ -243,9 +325,9 @@ export class MilestoneDetail { private buildMilestone(): MilestoneEntity { if (this.route.snapshot.routeConfig?.path === 'milestones/new') { - return { id: 0, name: '', description: '', dueDate: '', issueIds: [] }; + return { id: 0, name: '', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [], dependsOnIds: [] }; } const id = Number(this.route.snapshot.paramMap.get('id') ?? 0); - return this.milestonesStore.getById(id) ?? { id, name: '', description: '', dueDate: '', issueIds: [] }; + return this.milestonesStore.getById(id) ?? { id, name: '', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [], dependsOnIds: [] }; } } diff --git a/src/app/milestones/milestones-api.service.spec.ts b/src/app/milestones/milestones-api.service.spec.ts index 7270385..2eb67e1 100644 --- a/src/app/milestones/milestones-api.service.spec.ts +++ b/src/app/milestones/milestones-api.service.spec.ts @@ -10,8 +10,11 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit id: 1, name: 'Sprint 1', description: '', + startDate: '', + endDate: '', dueDate: '', issueIds: [], + dependsOnIds: [], ...overrides, }); @@ -43,7 +46,7 @@ describe('MilestonesApiService', () => { describe('create', () => { it('sends POST /api/milestones with the body and returns the created milestone', () => { - const body = { name: 'Sprint 2', description: '', dueDate: '', issueIds: [] }; + const body = { name: 'Sprint 2', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [], dependsOnIds: [] }; const response = makeMilestone({ id: 2, name: 'Sprint 2' }); let result: MilestoneEntity | undefined; service.create(body).subscribe((data) => (result = data)); diff --git a/src/app/milestones/milestones.html b/src/app/milestones/milestones.html index 86ea69d..9a5c3f5 100644 --- a/src/app/milestones/milestones.html +++ b/src/app/milestones/milestones.html @@ -4,9 +4,16 @@

Milestones

Objectifs et jalons du projet.

- +
+
+ + +
+ +
+@if (viewMode === 'list') {
+} +@if (viewMode === 'gantt') { +
+
Diagramme Gantt
+
+ +
+
+} + +@if (viewMode === 'list') {
@@ -25,6 +43,8 @@ + + @@ -41,6 +61,8 @@ + + - + }
# Nom DescriptionDébutFin Échéance Issues Progression#{{ milestone.id }} {{ milestone.name }} {{ milestone.description }}{{ formatDate(milestone.startDate) }}{{ formatDate(milestone.endDate) }} {{ formatDate(milestone.dueDate) }} {{ milestone.issueIds.length }} @@ -64,10 +86,11 @@ } @if (filteredMilestones.length === 0) {
Aucun milestone trouvé.Aucun milestone trouvé.
+} diff --git a/src/app/milestones/milestones.store.spec.ts b/src/app/milestones/milestones.store.spec.ts index 6ae878c..6ddff4c 100644 --- a/src/app/milestones/milestones.store.spec.ts +++ b/src/app/milestones/milestones.store.spec.ts @@ -9,8 +9,11 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit id: 1, name: 'Sprint 1', description: '', + startDate: '', + endDate: '', dueDate: '', issueIds: [], + dependsOnIds: [], ...overrides, }); @@ -142,5 +145,21 @@ describe('MilestonesStore', () => { const result = await p; expect(result.issueIds).toEqual([1]); }); + + it('normalizes dependsOnIds to empty array when not an array', async () => { + const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: null } as any; + const p = store.upsert(raw); + httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush({ ...raw, dependsOnIds: null }); + const result = await p; + expect(result.dependsOnIds).toEqual([]); + }); + + it('filters non-number values from dependsOnIds', async () => { + const raw = { id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: [2, 'bad', null] } as any; + const p = store.upsert(raw); + httpMock.expectOne({ method: 'PUT', url: `${API}/milestones/1` }).flush({ id: 1, name: 'Sprint', description: '', dueDate: '', issueIds: [], dependsOnIds: [2] }); + const result = await p; + expect(result.dependsOnIds).toEqual([2]); + }); }); }); diff --git a/src/app/milestones/milestones.store.ts b/src/app/milestones/milestones.store.ts index d844c89..59f13fe 100644 --- a/src/app/milestones/milestones.store.ts +++ b/src/app/milestones/milestones.store.ts @@ -6,8 +6,11 @@ export type MilestoneEntity = { id: number; name: string; description: string; + startDate: string; + endDate: string; dueDate: string; issueIds: number[]; + dependsOnIds: number[]; }; @Injectable({ providedIn: 'root' }) @@ -67,10 +70,15 @@ export class MilestonesStore { id: milestone.id ?? 0, name: milestone.name ?? '', description: milestone.description ?? '', + startDate: milestone.startDate ?? '', + endDate: milestone.endDate ?? '', dueDate: milestone.dueDate ?? '', issueIds: Array.isArray(milestone.issueIds) ? milestone.issueIds.filter((v): v is number => typeof v === 'number') : [], + dependsOnIds: Array.isArray(milestone.dependsOnIds) + ? milestone.dependsOnIds.filter((v): v is number => typeof v === 'number') + : [], }; } } diff --git a/src/app/milestones/milestones.ts b/src/app/milestones/milestones.ts index 8ad6bb5..836214e 100644 --- a/src/app/milestones/milestones.ts +++ b/src/app/milestones/milestones.ts @@ -3,10 +3,11 @@ import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { IssuesStore } from '../issues/issues.store'; import { MilestoneEntity, MilestonesStore } from './milestones.store'; +import { GanttDiagram, GanttTask } from '../shared/gantt-diagram/gantt-diagram'; @Component({ selector: 'app-milestones', - imports: [FormsModule], + imports: [FormsModule, GanttDiagram], templateUrl: './milestones.html', styleUrl: './milestones.css', }) @@ -22,11 +23,65 @@ export class Milestones { protected readonly milestones = this.milestonesStore.milestones; protected searchQuery = ''; + protected viewMode: 'list' | 'gantt' = 'list'; + + protected get ganttTasks(): GanttTask[] { + const today = new Date().toISOString().slice(0, 10); + const tasks: GanttTask[] = []; + const taskIds = new Set(); + + const sorted = [...this.milestones()].sort((a, b) => { + if (!a.startDate && !b.startDate) return 0; + if (!a.startDate) return 1; + if (!b.startDate) return -1; + return a.startDate.localeCompare(b.startDate); + }); + + for (const milestone of sorted) { + const end = milestone.endDate || milestone.dueDate; + if (!end) continue; + + let start = milestone.startDate; + if (!start) { + const linkedIssues = this.issuesStore.issues().filter((i) => + milestone.issueIds.includes(i.id), + ); + const starts = linkedIssues.map((i) => i.startDate).filter(Boolean); + start = starts.length > 0 + ? starts.reduce((min, d) => (d < min ? d : min)) + : today; + } + const clampedEnd = end < start ? start : end; + + const deps = milestone.dependsOnIds + .map((id) => `milestone-${id}`) + .filter((id) => taskIds.has(id)) + .join(','); + tasks.push({ + id: `milestone-${milestone.id}`, + name: milestone.name, + start, + end: clampedEnd, + progress: this.getProgress(milestone), + ...(deps ? { dependencies: deps } : {}), + }); + taskIds.add(`milestone-${milestone.id}`); + } + + return tasks; + } protected get filteredMilestones(): MilestoneEntity[] { const q = this.searchQuery.trim().toLowerCase(); - if (!q) return this.milestones(); - return this.milestones().filter((m) => m.name.toLowerCase().includes(q)); + const list = q + ? this.milestones().filter((m) => m.name.toLowerCase().includes(q)) + : this.milestones(); + return [...list].sort((a, b) => { + if (!a.startDate && !b.startDate) return 0; + if (!a.startDate) return 1; + if (!b.startDate) return -1; + return a.startDate.localeCompare(b.startDate); + }); } protected getProgress(milestone: MilestoneEntity): number { diff --git a/src/app/shared/gantt-diagram/gantt-diagram.css b/src/app/shared/gantt-diagram/gantt-diagram.css new file mode 100644 index 0000000..ae452d2 --- /dev/null +++ b/src/app/shared/gantt-diagram/gantt-diagram.css @@ -0,0 +1,27 @@ +:host { + display: block; + --g-bar-color: #f0fdf4; + --g-bar-border: #9ae6b4; + --g-progress-color: #38a169; + --g-expected-progress: #2f855a; + --g-handle-color: #744210; + --g-today-highlight: #276749; +} + +.gantt-toolbar { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.gantt-wrapper { + overflow-x: auto; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + background: #fff; +} + +.gantt-container { + min-width: 600px; + padding: 0.5rem 0; +} diff --git a/src/app/shared/gantt-diagram/gantt-diagram.html b/src/app/shared/gantt-diagram/gantt-diagram.html new file mode 100644 index 0000000..801f1a9 --- /dev/null +++ b/src/app/shared/gantt-diagram/gantt-diagram.html @@ -0,0 +1,25 @@ +@if (hasTasks) { +
+ @for (mode of viewModes; track mode) { + + } + +
+
+
+
+} @else { +

+ Aucune tâche avec des dates de début et de fin définies. +

+} diff --git a/src/app/shared/gantt-diagram/gantt-diagram.spec.ts b/src/app/shared/gantt-diagram/gantt-diagram.spec.ts new file mode 100644 index 0000000..b4a747b --- /dev/null +++ b/src/app/shared/gantt-diagram/gantt-diagram.spec.ts @@ -0,0 +1,254 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { GanttDiagram, GanttTask } from './gantt-diagram'; +import { vi, afterEach } from 'vitest'; + +vi.mock('frappe-gantt', () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + default: vi.fn().mockImplementation(class {} as any), +})); + +const makeTask = (overrides: Partial = {}): GanttTask => ({ + id: 'task-1', + name: 'Tâche test', + start: '2026-06-01', + end: '2026-06-15', + progress: 50, + ...overrides, +}); + +const makeFakeGantt = () => ({ + refresh: vi.fn(), + change_view_mode: vi.fn(), +}); + +const makeFakeGanttWithScroll = (today = new Date()) => { + const ganttStart = new Date(today.getFullYear(), today.getMonth() - 2, 1); + const ganttEnd = new Date(today.getFullYear(), today.getMonth() + 4, 1); + return { + refresh: vi.fn(), + change_view_mode: vi.fn(), + scroll_current: vi.fn(), + gantt_start: ganttStart, + gantt_end: ganttEnd, + config: { column_width: 120 }, + $container: { scrollTo: vi.fn() }, + }; +}; + +describe('GanttDiagram', () => { + let fixture: ComponentFixture; + let component: GanttDiagram; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GanttDiagram], + }).compileComponents(); + + fixture = TestBed.createComponent(GanttDiagram); + component = fixture.componentInstance; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('état initial', () => { + it('affiche le message vide quand aucune tâche', () => { + component.tasks = []; + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement; + expect(el.textContent).toContain('Aucune tâche avec des dates'); + }); + + it('ne rend pas le conteneur gantt quand aucune tâche', () => { + component.tasks = []; + fixture.detectChanges(); + const container = fixture.nativeElement.querySelector('.gantt-container'); + expect(container).toBeNull(); + }); + }); + + describe('avec des tâches', () => { + it('affiche le conteneur gantt quand des tâches sont fournies', () => { + component.tasks = [makeTask()]; + fixture.detectChanges(); + const container = fixture.nativeElement.querySelector('.gantt-container'); + expect(container).not.toBeNull(); + }); + + it('affiche les boutons de mode de vue et le bouton Aujourd\'hui', () => { + component.tasks = [makeTask()]; + fixture.detectChanges(); + const buttons: NodeListOf = + fixture.nativeElement.querySelectorAll('.gantt-toolbar button'); + expect(buttons.length).toBe(4); + const labels = Array.from(buttons).map((b) => b.textContent?.trim()); + expect(labels).toContain('Jour'); + expect(labels).toContain('Semaine'); + expect(labels).toContain('Mois'); + expect(labels).toContain("Aujourd'hui"); + }); + + it('active le mode Semaine par défaut', () => { + component.tasks = [makeTask()]; + fixture.detectChanges(); + const buttons: NodeListOf = + fixture.nativeElement.querySelectorAll('.gantt-toolbar button'); + const active = Array.from(buttons).find((b) => + b.classList.contains('btn-primary'), + ); + expect(active?.textContent?.trim()).toBe('Semaine'); + }); + }); + + describe('changement de mode de vue', () => { + it('met à jour activeViewMode au clic sur Mois', () => { + component.tasks = [makeTask()]; + fixture.detectChanges(); + const buttons: NodeListOf = + fixture.nativeElement.querySelectorAll('.gantt-toolbar button'); + const monthBtn = Array.from(buttons).find( + (b) => b.textContent?.trim() === 'Mois', + ); + monthBtn?.click(); + fixture.detectChanges(); + expect(component['activeViewMode']).toBe('Month'); + }); + + it('le bouton actif change après le clic sur Jour', () => { + component.tasks = [makeTask()]; + fixture.detectChanges(); + const buttons: NodeListOf = + fixture.nativeElement.querySelectorAll('.gantt-toolbar button'); + const dayBtn = Array.from(buttons).find( + (b) => b.textContent?.trim() === 'Jour', + ); + dayBtn?.click(); + fixture.detectChanges(); + expect(dayBtn?.classList.contains('btn-primary')).toBe(true); + }); + + it("appelle change_view_mode sur le gantt si initialisé", () => { + const fakeGantt = makeFakeGantt(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (component as any)['gantt'] = fakeGantt; + component['setViewMode']('Month'); + expect(fakeGantt.change_view_mode).toHaveBeenCalledWith('Month'); + }); + + it("ne plante pas si le gantt n'est pas encore initialisé", () => { + expect(() => component['setViewMode']('Month')).not.toThrow(); + }); + }); + + describe('ngOnChanges', () => { + it('rafraîchit le diagramme quand les tâches changent et le gantt est initialisé', () => { + const fakeGantt = makeFakeGantt(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (component as any)['gantt'] = fakeGantt; + (component as any)['initialized'] = true; + const newTasks = [makeTask({ id: 'task-2', name: 'Nouvelle tâche' })]; + component.tasks = newTasks; + component.ngOnChanges({ + tasks: { currentValue: newTasks, previousValue: [], firstChange: false, isFirstChange: () => false }, + }); + expect(fakeGantt.refresh).toHaveBeenCalledWith(newTasks); + }); + + it("ne lève pas d'erreur si appelé avant AfterViewInit", () => { + expect(() => { + component.ngOnChanges({ + tasks: { currentValue: [makeTask()], previousValue: [], firstChange: false, isFirstChange: () => false }, + }); + }).not.toThrow(); + }); + + it('appelle renderGantt si le gantt est null au changement de tâches', () => { + fixture.detectChanges(); // init sans tâches : AfterViewInit ne rend rien + const renderSpy = vi.spyOn(component as any, 'renderGantt'); + (component as any)['gantt'] = null; + const newTasks = [makeTask()]; + component.tasks = newTasks; + component.ngOnChanges({ + tasks: { currentValue: newTasks, previousValue: [], firstChange: false, isFirstChange: () => false }, + }); + expect(renderSpy).toHaveBeenCalled(); + }); + }); + + describe('hasTasks', () => { + it('retourne false quand tasks est vide', () => { + component.tasks = []; + expect(component.hasTasks).toBe(false); + }); + + it('retourne true quand des tâches existent', () => { + component.tasks = [makeTask()]; + expect(component.hasTasks).toBe(true); + }); + }); + + describe('scrollToToday', () => { + it('ne fait rien si le gantt n\'est pas initialisé', () => { + expect(() => component['scrollToToday']()).not.toThrow(); + }); + + it('ne fait rien si aujourd\'hui est hors de la plage du gantt', () => { + const fakeGantt = makeFakeGanttWithScroll(); + fakeGantt.gantt_start = new Date(2030, 0, 1); + fakeGantt.gantt_end = new Date(2030, 11, 31); + (component as any)['gantt'] = fakeGantt; + component['scrollToToday'](); + expect(fakeGantt.$container.scrollTo).not.toHaveBeenCalled(); + expect(fakeGantt.scroll_current).not.toHaveBeenCalled(); + }); + + it('appelle scrollTo avec la bonne position en vue Mois', () => { + const today = new Date(); + const fakeGantt = makeFakeGanttWithScroll(today); + (component as any)['gantt'] = fakeGantt; + (component as any)['activeViewMode'] = 'Month'; + component['scrollToToday'](); + const monthDiff = + (today.getFullYear() - fakeGantt.gantt_start.getFullYear()) * 12 + + today.getMonth() - fakeGantt.gantt_start.getMonth(); + const expectedLeft = Math.max(0, monthDiff * 120 - 120 / 6); + expect(fakeGantt.$container.scrollTo).toHaveBeenCalledWith({ + left: expectedLeft, + behavior: 'smooth', + }); + }); + + it('appelle scroll_current en vue Semaine', () => { + const fakeGantt = makeFakeGanttWithScroll(); + (component as any)['gantt'] = fakeGantt; + (component as any)['activeViewMode'] = 'Week'; + component['scrollToToday'](); + expect(fakeGantt.scroll_current).toHaveBeenCalled(); + expect(fakeGantt.$container.scrollTo).not.toHaveBeenCalled(); + }); + + it('appelle scroll_current en vue Jour', () => { + const fakeGantt = makeFakeGanttWithScroll(); + (component as any)['gantt'] = fakeGantt; + (component as any)['activeViewMode'] = 'Day'; + component['scrollToToday'](); + expect(fakeGantt.scroll_current).toHaveBeenCalled(); + }); + }); + + describe('ngOnDestroy', () => { + it('met gantt à null à la destruction', () => { + const fakeGantt = makeFakeGantt(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (component as any)['gantt'] = fakeGantt; + component.ngOnDestroy(); + expect(component['gantt']).toBeNull(); + }); + + it('marque le composant comme détruit', () => { + component.ngOnDestroy(); + expect(component['destroyed']).toBe(true); + }); + }); +}); diff --git a/src/app/shared/gantt-diagram/gantt-diagram.ts b/src/app/shared/gantt-diagram/gantt-diagram.ts new file mode 100644 index 0000000..7a63262 --- /dev/null +++ b/src/app/shared/gantt-diagram/gantt-diagram.ts @@ -0,0 +1,120 @@ +import { + AfterViewInit, + Component, + ElementRef, + Input, + NgZone, + OnChanges, + OnDestroy, + SimpleChanges, + ViewChild, +} from '@angular/core'; +import type Gantt from 'frappe-gantt'; +import type { GanttTask } from 'frappe-gantt'; + +export type { GanttTask }; + +type ViewMode = 'Day' | 'Week' | 'Month'; + +@Component({ + selector: 'app-gantt-diagram', + templateUrl: './gantt-diagram.html', + styleUrl: './gantt-diagram.css', +}) +export class GanttDiagram implements AfterViewInit, OnChanges, OnDestroy { + @ViewChild('ganttContainer') ganttContainer!: ElementRef; + @Input() tasks: GanttTask[] = []; + + protected readonly viewModes: ViewMode[] = ['Day', 'Week', 'Month']; + protected readonly viewModeLabels: Record = { + Day: 'Jour', + Week: 'Semaine', + Month: 'Mois', + }; + protected activeViewMode: ViewMode = 'Week'; + + private gantt: Gantt | null = null; + private initialized = false; + private destroyed = false; + + constructor(private readonly zone: NgZone) {} + + ngAfterViewInit(): void { + this.initialized = true; + this.renderGantt(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (!this.initialized) return; + if (changes['tasks']) { + if (this.gantt) { + this.zone.runOutsideAngular(() => this.gantt!.refresh(this.tasks)); + } else { + this.renderGantt(); + } + } + } + + ngOnDestroy(): void { + this.destroyed = true; + this.gantt = null; + } + + protected setViewMode(mode: ViewMode): void { + this.activeViewMode = mode; + if (this.gantt) { + this.zone.runOutsideAngular(() => this.gantt!.change_view_mode(mode)); + } + } + + protected scrollToToday(): void { + if (!this.gantt) return; + this.zone.runOutsideAngular(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = this.gantt as any; + const today = new Date(); + if (today < g.gantt_start || today > g.gantt_end) return; + + if (this.activeViewMode === 'Month') { + // Bug frappe-gantt : get_closest_date() construit new Date("YYYY-MM ") + // qui est un Invalid Date → position NaN → aucun scroll. + // Correctif : calcul entier du décalage en mois. + const monthDiff = + (today.getFullYear() - g.gantt_start.getFullYear()) * 12 + + today.getMonth() - g.gantt_start.getMonth(); + const left = Math.max( + 0, + monthDiff * g.config.column_width - g.config.column_width / 6, + ); + g.$container.scrollTo({ left, behavior: 'smooth' }); + } else { + g.scroll_current(); + } + }); + } + + get hasTasks(): boolean { + return this.tasks.length > 0; + } + + private renderGantt(): void { + if (!this.tasks.length || !this.ganttContainer) return; + + import('frappe-gantt').then(({ default: GanttClass }) => { + if (this.destroyed || !this.ganttContainer) return; + this.zone.runOutsideAngular(() => { + this.ganttContainer.nativeElement.innerHTML = ''; + this.gantt = new GanttClass( + this.ganttContainer.nativeElement, + this.tasks, + { + view_mode: this.activeViewMode, + language: 'fr', + popup: false, + today_button: false, + }, + ); + }); + }); + } +} diff --git a/src/frappe-gantt.d.ts b/src/frappe-gantt.d.ts new file mode 100644 index 0000000..9e8bfa3 --- /dev/null +++ b/src/frappe-gantt.d.ts @@ -0,0 +1,28 @@ +declare module 'frappe-gantt' { + export interface GanttTask { + id: string; + name: string; + start: string; + end: string; + progress: number; + dependencies?: string; + custom_class?: string; + } + + export interface GanttOptions { + view_mode?: string; + language?: string; + popup?: boolean | ((task: GanttTask, x: number, y: number) => string); + today_button?: boolean; + on_click?: (task: GanttTask) => void; + on_date_change?: (task: GanttTask, start: Date, end: Date) => void; + on_progress_change?: (task: GanttTask, progress: number) => void; + on_view_change?: (mode: string) => void; + } + + export default class Gantt { + constructor(element: string | HTMLElement, tasks: GanttTask[], options?: GanttOptions); + refresh(tasks: GanttTask[]): void; + change_view_mode(mode?: string): void; + } +}