From 401da09f8fae1be5d4247fe23d83f160ca95ae00 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 30 May 2026 14:43:06 +0200 Subject: [PATCH] =?UTF-8?q?#62=20Ajoute=20d'une=20configuration=20pour=20l?= =?UTF-8?q?es=20status=20=C3=A0=20consid=C3=A9rer=20comme=20termin=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gato --- src/app/dashboard/dashboard.spec.ts | 31 +++++++++++++++++ src/app/dashboard/dashboard.ts | 13 ++++--- .../issues/issue-comments/issue-comments.ts | 2 +- src/app/issues/issue-detail/issue-detail.ts | 2 +- src/app/issues/issues.spec.ts | 31 +++++++++++++++++ src/app/issues/issues.ts | 4 +-- .../milestone-detail/milestone-detail.spec.ts | 32 +++++++++++++++++ .../milestone-detail/milestone-detail.ts | 4 +-- src/app/milestones/milestones.ts | 4 ++- .../statuses/statuses-api.service.spec.ts | 3 +- .../settings/statuses/statuses-api.service.ts | 2 +- src/app/settings/statuses/statuses.html | 15 ++++++++ src/app/settings/statuses/statuses.spec.ts | 30 ++++++++++------ .../settings/statuses/statuses.store.spec.ts | 34 ++++++++++++++++--- src/app/settings/statuses/statuses.store.ts | 15 +++++--- src/app/settings/statuses/statuses.ts | 9 ++--- src/app/statuses/statuses.html | 15 ++++++++ src/app/statuses/statuses.spec.ts | 6 ++-- src/app/statuses/statuses.store.spec.ts | 27 +++++++++++++-- src/app/statuses/statuses.store.ts | 15 +++++--- src/app/statuses/statuses.ts | 10 +++--- 21 files changed, 251 insertions(+), 53 deletions(-) diff --git a/src/app/dashboard/dashboard.spec.ts b/src/app/dashboard/dashboard.spec.ts index 60bbf6d..5011ec8 100644 --- a/src/app/dashboard/dashboard.spec.ts +++ b/src/app/dashboard/dashboard.spec.ts @@ -5,6 +5,7 @@ import { vi } from 'vitest'; import { Dashboard } from './dashboard'; import { IssueEntity, IssuesStore } from '../issues/issues.store'; import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store'; +import { StatusesStore } from '../statuses/statuses.store'; const makeIssue = (overrides: Partial = {}): IssueEntity => ({ id: 1, @@ -43,6 +44,16 @@ class FakeIssuesStore { deleteById = vi.fn(); } +class FakeStatusesStore { + private completedIds = new Set(['done']); + + setCompleted(ids: string[]): void { + this.completedIds = new Set(ids); + } + + isCompleted = vi.fn((statusId: string) => this.completedIds.has(statusId)); +} + class FakeMilestonesStore { private _data = signal([]); readonly milestones = this._data.asReadonly(); @@ -63,11 +74,13 @@ describe('Dashboard', () => { let fixture: ComponentFixture; let issuesStore: FakeIssuesStore; let milestonesStore: FakeMilestonesStore; + let statusesStore: FakeStatusesStore; let router: Router; beforeEach(async () => { issuesStore = new FakeIssuesStore(); milestonesStore = new FakeMilestonesStore(); + statusesStore = new FakeStatusesStore(); await TestBed.configureTestingModule({ imports: [Dashboard], @@ -75,6 +88,7 @@ describe('Dashboard', () => { provideRouter([]), { provide: IssuesStore, useValue: issuesStore }, { provide: MilestonesStore, useValue: milestonesStore }, + { provide: StatusesStore, useValue: statusesStore }, ], }).compileComponents(); @@ -123,6 +137,17 @@ describe('Dashboard', () => { ]); expect((component as any).completionRate()).toBe(50); }); + + it('compte un statut personnalisé countsAsCompleted comme terminé', () => { + statusesStore.setCompleted(['done', 'abandoned']); + issuesStore.seed([ + makeIssue({ id: 1, status: 'done' }), + makeIssue({ id: 2, status: 'abandoned' }), + makeIssue({ id: 3, status: 'todo' }), + makeIssue({ id: 4, status: 'todo' }), + ]); + expect((component as any).completionRate()).toBe(50); + }); }); describe('statusCounts', () => { @@ -180,6 +205,12 @@ describe('Dashboard', () => { expect((component as any).highPriorityIssues().length).toBe(0); }); + it('exclut les issues avec un statut personnalisé countsAsCompleted', () => { + statusesStore.setCompleted(['done', 'abandoned']); + issuesStore.seed([makeIssue({ priority: 'TRES_HAUTE', status: 'abandoned' })]); + expect((component as any).highPriorityIssues().length).toBe(0); + }); + it('limite à 6 résultats', () => { issuesStore.seed( Array.from({ length: 10 }, (_, i) => diff --git a/src/app/dashboard/dashboard.ts b/src/app/dashboard/dashboard.ts index 741d3c7..6166de5 100644 --- a/src/app/dashboard/dashboard.ts +++ b/src/app/dashboard/dashboard.ts @@ -2,6 +2,7 @@ import { Component, computed, inject } from '@angular/core'; import { Router } from '@angular/router'; import { IssueEntity, IssuesStore } from '../issues/issues.store'; import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store'; +import { StatusesStore } from '../statuses/statuses.store'; @Component({ selector: 'app-dashboard', @@ -13,6 +14,7 @@ export class Dashboard { private readonly router = inject(Router); private readonly issuesStore = inject(IssuesStore); private readonly milestonesStore = inject(MilestonesStore); + private readonly statusesStore = inject(StatusesStore); constructor() { this.issuesStore.load(); @@ -31,7 +33,8 @@ export class Dashboard { protected readonly completionRate = computed(() => { const total = this.totalIssues(); if (total === 0) return 0; - return Math.round((this.statusCounts().done / total) * 100); + const done = this.issuesStore.issues().filter((i) => this.statusesStore.isCompleted(i.status)).length; + return Math.round((done / total) * 100); }); protected readonly totalMilestones = computed(() => this.milestonesStore.milestones().length); @@ -57,7 +60,7 @@ export class Dashboard { protected readonly highPriorityIssues = computed(() => this.issuesStore .issues() - .filter((i) => (i.priority === 'HAUTE' || i.priority === 'TRES_HAUTE') && i.status !== 'done') + .filter((i) => (i.priority === 'HAUTE' || i.priority === 'TRES_HAUTE') && !this.statusesStore.isCompleted(i.status)) .slice(0, 6), ); @@ -75,7 +78,7 @@ export class Dashboard { return this.issuesStore .issues() .filter((i) => { - if (!i.dueDate || i.status === 'done') return false; + if (!i.dueDate || this.statusesStore.isCompleted(i.status)) return false; const due = new Date(i.dueDate); return due >= now && due <= twoWeeks; }) @@ -86,7 +89,7 @@ export class Dashboard { protected readonly overdueCount = computed(() => { const now = new Date(); return this.issuesStore.issues().filter((i) => { - if (!i.dueDate || i.status === 'done') return false; + if (!i.dueDate || this.statusesStore.isCompleted(i.status)) return false; return new Date(i.dueDate) < now; }).length; }); @@ -95,7 +98,7 @@ export class Dashboard { if (milestone.issueIds.length === 0) return 0; const linked = this.issuesStore.issues().filter((i) => milestone.issueIds.includes(i.id)); if (linked.length === 0) return 0; - return Math.round((linked.filter((i) => i.status === 'done').length / linked.length) * 100); + return Math.round((linked.filter((i) => this.statusesStore.isCompleted(i.status)).length / linked.length) * 100); } protected formatDate(iso: string): string { diff --git a/src/app/issues/issue-comments/issue-comments.ts b/src/app/issues/issue-comments/issue-comments.ts index 1f9e920..d682938 100644 --- a/src/app/issues/issue-comments/issue-comments.ts +++ b/src/app/issues/issue-comments/issue-comments.ts @@ -145,7 +145,7 @@ export class IssueComments { } protected statusLabel(status: IssueEntity['status']): StatusEntity { - return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 }; + return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99, countsAsCompleted: false }; } protected startCreateTask(commentId: number): void { diff --git a/src/app/issues/issue-detail/issue-detail.ts b/src/app/issues/issue-detail/issue-detail.ts index 6838e2e..5e4871d 100644 --- a/src/app/issues/issue-detail/issue-detail.ts +++ b/src/app/issues/issue-detail/issue-detail.ts @@ -446,7 +446,7 @@ export class IssueDetail { } protected statusBadge(status: IssueEntity['status']): StatusEntity { - return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 }; + return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99, countsAsCompleted: false }; } protected priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string; label: string } { diff --git a/src/app/issues/issues.spec.ts b/src/app/issues/issues.spec.ts index 4fb956e..9289b88 100644 --- a/src/app/issues/issues.spec.ts +++ b/src/app/issues/issues.spec.ts @@ -6,6 +6,7 @@ import { vi } from 'vitest'; import { Issues } from './issues'; import { IssueEntity, IssuesStore } from './issues.store'; import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store'; +import { StatusesStore } from '../statuses/statuses.store'; const makeIssue = (overrides: Partial = {}): IssueEntity => ({ id: 99, @@ -104,6 +105,24 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit ...overrides, }); +class FakeStatusesStore { + private completedIds = new Set(['done']); + + setCompleted(ids: string[]): void { + this.completedIds = new Set(ids); + } + + isCompleted = vi.fn((statusId: string) => this.completedIds.has(statusId)); + getById = vi.fn((id: string) => + id === 'done' + ? { id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true } + : undefined, + ); + readonly statuses = signal([ + { id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true }, + ]); +} + class FakeMilestonesStore { private _data = signal([]); @@ -141,17 +160,20 @@ describe('Issues', () => { let fixture: ComponentFixture; let store: FakeIssuesStore; let milestonesStore: FakeMilestonesStore; + let statusesStore: FakeStatusesStore; let router: Router; beforeEach(async () => { store = new FakeIssuesStore(); milestonesStore = new FakeMilestonesStore(); + statusesStore = new FakeStatusesStore(); await TestBed.configureTestingModule({ imports: [Issues], providers: [ provideRouter([]), { provide: IssuesStore, useValue: store }, { provide: MilestonesStore, useValue: milestonesStore }, + { provide: StatusesStore, useValue: statusesStore }, ], }).compileComponents(); @@ -308,6 +330,15 @@ describe('Issues', () => { store.upsert(makeIssue({ id: 58, name: 'DepChild', dependsOnIds: [57], status: 'done' })); expect((component as any).getProgress(epic)).toBe(100); }); + + it('counts a custom countsAsCompleted status as done for an Epic', () => { + statusesStore.setCompleted(['done', 'abandoned']); + const epic = makeIssue({ id: 60, type: 'Epic', name: 'Custom Epic', progress: 0 }); + store.upsert(epic); + store.upsert(makeIssue({ id: 61, name: 'Done Child', epic: 'Custom Epic', status: 'abandoned' })); + store.upsert(makeIssue({ id: 62, name: 'Todo Child', epic: 'Custom Epic', status: 'todo' })); + expect((component as any).getProgress(epic)).toBe(50); + }); }); describe('getMilestoneForIssue', () => { diff --git a/src/app/issues/issues.ts b/src/app/issues/issues.ts index 5794e5a..4a1c3f8 100644 --- a/src/app/issues/issues.ts +++ b/src/app/issues/issues.ts @@ -153,7 +153,7 @@ export class Issues { (i) => i.id !== issue.id && (i.epic === issue.name || i.dependsOnIds.includes(issue.id)), ); if (children.length === 0) return 0; - const done = children.filter((i) => i.status === 'done').length; + const done = children.filter((i) => this.statusesStore.isCompleted(i.status)).length; return Math.round((done / children.length) * 100); } @@ -193,6 +193,6 @@ export class Issues { } protected statusBadge(status: IssueStatus): StatusEntity { - return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 }; + return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99, countsAsCompleted: false }; } } diff --git a/src/app/milestones/milestone-detail/milestone-detail.spec.ts b/src/app/milestones/milestone-detail/milestone-detail.spec.ts index 8ee5fbe..d4f8a95 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.spec.ts +++ b/src/app/milestones/milestone-detail/milestone-detail.spec.ts @@ -7,6 +7,7 @@ import { afterEach, vi } from 'vitest'; import { MilestoneDetail } from './milestone-detail'; import { IssueEntity, IssuesStore } from '../../issues/issues.store'; import { MilestoneEntity, MilestonesStore } from '../milestones.store'; +import { StatusesStore } from '../../settings/statuses/statuses.store'; const makeIssue = (overrides: Partial = {}): IssueEntity => ({ id: 99, @@ -72,6 +73,24 @@ class FakeIssuesStore { } } +const DEFAULT_STATUS_MAP: Record = { + draft: { id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0, countsAsCompleted: false }, + todo: { id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1, countsAsCompleted: false }, + 'in-progress':{ id: 'in-progress', label: 'EN COURS', bg: '#ffedd5', color: '#9a3412', order: 2, countsAsCompleted: false }, + done: { id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true }, +}; + +class FakeStatusesStore { + private completedIds = new Set(['done']); + + setCompleted(ids: string[]): void { + this.completedIds = new Set(ids); + } + + isCompleted = vi.fn((statusId: string) => this.completedIds.has(statusId)); + getById = vi.fn((id: string) => DEFAULT_STATUS_MAP[id]); +} + class FakeMilestonesStore { private _data = signal([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]); @@ -123,11 +142,13 @@ describe('MilestoneDetail', () => { let fixture: ComponentFixture; let issuesStore: FakeIssuesStore; let milestonesStore: FakeMilestonesStore; + let statusesStore: FakeStatusesStore; let router: Router; beforeEach(async () => { issuesStore = new FakeIssuesStore(); milestonesStore = new FakeMilestonesStore(); + statusesStore = new FakeStatusesStore(); await TestBed.configureTestingModule({ imports: [MilestoneDetail], providers: [ @@ -135,6 +156,7 @@ describe('MilestoneDetail', () => { { provide: ActivatedRoute, useValue: makeRoute('1') }, { provide: IssuesStore, useValue: issuesStore }, { provide: MilestonesStore, useValue: milestonesStore }, + { provide: StatusesStore, useValue: statusesStore }, ], }).compileComponents(); @@ -277,6 +299,16 @@ describe('MilestoneDetail', () => { (component as any).milestone.issueIds = [1, 2]; expect((component as any).progress).toBe(50); }); + + it('counts a custom countsAsCompleted status as done', () => { + statusesStore.setCompleted(['done', 'abandoned']); + issuesStore.seed([ + makeIssue({ id: 1, status: 'abandoned' }), + makeIssue({ id: 2, status: 'todo' }), + ]); + (component as any).milestone.issueIds = [1, 2]; + expect((component as any).progress).toBe(50); + }); }); describe('milestoneGanttTasks', () => { diff --git a/src/app/milestones/milestone-detail/milestone-detail.ts b/src/app/milestones/milestone-detail/milestone-detail.ts index 482c112..d6f5636 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.ts +++ b/src/app/milestones/milestone-detail/milestone-detail.ts @@ -185,7 +185,7 @@ export class MilestoneDetail { protected get progress(): number { if (this.linkedIssues.length === 0) return 0; return Math.round( - (this.linkedIssues.filter((i) => i.status === 'done').length / this.linkedIssues.length) * 100, + (this.linkedIssues.filter((i) => this.statusesStore.isCompleted(i.status)).length / this.linkedIssues.length) * 100, ); } @@ -301,7 +301,7 @@ export class MilestoneDetail { } protected statusBadge(status: IssueEntity['status']): StatusEntity { - return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99 }; + return this.statusesStore.getById(status) ?? { id: status, label: status, bg: '#e2e8f0', color: '#475569', order: 99, countsAsCompleted: false }; } protected priorityDisplay(priority: IssueEntity['priority']): { symbol: string; color: string } { diff --git a/src/app/milestones/milestones.ts b/src/app/milestones/milestones.ts index 836214e..9eddcef 100644 --- a/src/app/milestones/milestones.ts +++ b/src/app/milestones/milestones.ts @@ -4,6 +4,7 @@ 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'; +import { StatusesStore } from '../statuses/statuses.store'; @Component({ selector: 'app-milestones', @@ -15,6 +16,7 @@ export class Milestones { private readonly router = inject(Router); private readonly milestonesStore = inject(MilestonesStore); private readonly issuesStore = inject(IssuesStore); + private readonly statusesStore = inject(StatusesStore); constructor() { this.milestonesStore.load(); @@ -88,7 +90,7 @@ export class Milestones { if (milestone.issueIds.length === 0) return 0; const linked = this.issuesStore.issues().filter((i) => milestone.issueIds.includes(i.id)); if (linked.length === 0) return 0; - return Math.round((linked.filter((i) => i.status === 'done').length / linked.length) * 100); + return Math.round((linked.filter((i) => this.statusesStore.isCompleted(i.status)).length / linked.length) * 100); } protected formatDate(iso: string): string { diff --git a/src/app/settings/statuses/statuses-api.service.spec.ts b/src/app/settings/statuses/statuses-api.service.spec.ts index d05ca03..b9f5c59 100644 --- a/src/app/settings/statuses/statuses-api.service.spec.ts +++ b/src/app/settings/statuses/statuses-api.service.spec.ts @@ -13,6 +13,7 @@ const makeStatus = (overrides: Partial = {}): StatusEntity => ({ bg: '#e2e8f0', color: '#475569', order: 0, + countsAsCompleted: false, ...overrides, }); @@ -45,7 +46,7 @@ describe('StatusesApiService', () => { describe('create', () => { it('sends POST /api/statuses with the status payload', () => { - const payload = { id: 'review', label: 'EN REVUE', bg: '#fff', color: '#000' }; + const payload = { id: 'review', label: 'EN REVUE', bg: '#fff', color: '#000', countsAsCompleted: false }; service.create(payload).subscribe(); const req = httpMock.expectOne(`${API}/statuses`); expect(req.request.method).toBe('POST'); diff --git a/src/app/settings/statuses/statuses-api.service.ts b/src/app/settings/statuses/statuses-api.service.ts index 36da2b9..2c5f1b4 100644 --- a/src/app/settings/statuses/statuses-api.service.ts +++ b/src/app/settings/statuses/statuses-api.service.ts @@ -18,7 +18,7 @@ export class StatusesApiService { return this.http.post(`${API_BASE_URL}/statuses`, status); } - update(id: string, changes: Partial>): Observable { + update(id: string, changes: Partial>): Observable { return this.http.put(`${API_BASE_URL}/statuses/${id}`, changes); } } diff --git a/src/app/settings/statuses/statuses.html b/src/app/settings/statuses/statuses.html index 1cdb4dc..8e05131 100644 --- a/src/app/settings/statuses/statuses.html +++ b/src/app/settings/statuses/statuses.html @@ -65,6 +65,18 @@ +
+ + +
+
+
+ + +
+