#62 Ajoute d'une configuration pour les status à considérer comme terminé

Signed-off-by: Gato <cedric@goutailler-olivier.fr>
This commit is contained in:
2026-05-30 14:43:06 +02:00
parent 43b275b064
commit 401da09f8f
21 changed files with 251 additions and 53 deletions
+31
View File
@@ -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> = {}): IssueEntity => ({
id: 1,
@@ -43,6 +44,16 @@ class FakeIssuesStore {
deleteById = vi.fn();
}
class FakeStatusesStore {
private completedIds = new Set<string>(['done']);
setCompleted(ids: string[]): void {
this.completedIds = new Set(ids);
}
isCompleted = vi.fn((statusId: string) => this.completedIds.has(statusId));
}
class FakeMilestonesStore {
private _data = signal<MilestoneEntity[]>([]);
readonly milestones = this._data.asReadonly();
@@ -63,11 +74,13 @@ describe('Dashboard', () => {
let fixture: ComponentFixture<Dashboard>;
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) =>
+8 -5
View File
@@ -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 {
@@ -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 {
+1 -1
View File
@@ -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 } {
+31
View File
@@ -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> = {}): IssueEntity => ({
id: 99,
@@ -104,6 +105,24 @@ const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntit
...overrides,
});
class FakeStatusesStore {
private completedIds = new Set<string>(['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<MilestoneEntity[]>([]);
@@ -141,17 +160,20 @@ describe('Issues', () => {
let fixture: ComponentFixture<Issues>;
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', () => {
+2 -2
View File
@@ -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 };
}
}
@@ -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> = {}): IssueEntity => ({
id: 99,
@@ -72,6 +73,24 @@ class FakeIssuesStore {
}
}
const DEFAULT_STATUS_MAP: Record<string, { id: string; label: string; bg: string; color: string; order: number; countsAsCompleted: boolean }> = {
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<string>(['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<MilestoneEntity[]>([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]);
@@ -123,11 +142,13 @@ describe('MilestoneDetail', () => {
let fixture: ComponentFixture<MilestoneDetail>;
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', () => {
@@ -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 } {
+3 -1
View File
@@ -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 {
@@ -13,6 +13,7 @@ const makeStatus = (overrides: Partial<StatusEntity> = {}): 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');
@@ -18,7 +18,7 @@ export class StatusesApiService {
return this.http.post<StatusEntity>(`${API_BASE_URL}/statuses`, status);
}
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color'>>): Observable<StatusEntity> {
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color' | 'countsAsCompleted'>>): Observable<StatusEntity> {
return this.http.put<StatusEntity>(`${API_BASE_URL}/statuses/${id}`, changes);
}
}
+15
View File
@@ -65,6 +65,18 @@
</div>
</div>
<div class="mb-3 form-check">
<input
id="status-counts-as-completed"
class="form-check-input"
type="checkbox"
[(ngModel)]="form.countsAsCompleted"
/>
<label class="form-check-label" for="status-counts-as-completed">
Compte comme terminé (inclus dans le calcul d'avancement)
</label>
</div>
<div class="status-form-actions">
<button class="btn btn-secondary btn-sm" (click)="cancel()">Annuler</button>
<button class="btn btn-primary btn-sm" [disabled]="!isFormValid()" (click)="save()">
@@ -84,6 +96,9 @@
[style.color]="status.color"
>{{ status.label }}</span>
<span class="status-id text-muted">{{ status.id }}</span>
@if (status.countsAsCompleted) {
<span class="status-completed-badge text-success small">✓ compte comme terminé</span>
}
<div class="status-row-actions">
<button
class="btn btn-outline-secondary btn-sm"
+20 -10
View File
@@ -6,15 +6,16 @@ import { StatusesStore } from './statuses.store';
const makeStoreMock = () => ({
statuses: vi.fn().mockReturnValue([
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0 },
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1 },
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0, countsAsCompleted: false },
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1, countsAsCompleted: false },
]),
getById: vi.fn((id: string) =>
[
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0 },
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1 },
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0, countsAsCompleted: false },
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1, countsAsCompleted: false },
].find((s) => s.id === id),
),
isCompleted: vi.fn((id: string) => id === 'done'),
create: vi.fn(),
update: vi.fn(),
});
@@ -56,7 +57,7 @@ describe('Statuses', () => {
it('calls store.create when saving a new status', async () => {
component['openCreate']();
component['form'] = { id: 'review', label: 'EN REVUE', bg: '#fff', color: '#000' };
component['form'] = { id: 'review', label: 'EN REVUE', bg: '#fff', color: '#000', countsAsCompleted: false };
component['save']();
expect(storeMock.create).toHaveBeenCalledWith(
expect.objectContaining({ label: 'EN REVUE' }),
@@ -64,8 +65,8 @@ describe('Statuses', () => {
});
it('calls store.update when saving an edited status', () => {
component['openEdit']({ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0 });
component['form'] = { id: 'draft', label: 'MODIFIÉ', bg: '#e2e8f0', color: '#475569' };
component['openEdit']({ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0, countsAsCompleted: false });
component['form'] = { id: 'draft', label: 'MODIFIÉ', bg: '#e2e8f0', color: '#475569', countsAsCompleted: false };
component['save']();
expect(storeMock.update).toHaveBeenCalledWith('draft', expect.objectContaining({ label: 'MODIFIÉ' }));
});
@@ -76,21 +77,30 @@ describe('Statuses', () => {
expect(component['formMode']()).toBeNull();
});
it('calls store.create with countsAsCompleted true when checked', () => {
component['openCreate']();
component['form'] = { id: 'abandoned', label: 'ABANDONNÉE', bg: '#f1f5f9', color: '#64748b', countsAsCompleted: true };
component['save']();
expect(storeMock.create).toHaveBeenCalledWith(
expect.objectContaining({ countsAsCompleted: true }),
);
});
it('shows an error when creating a duplicate id', () => {
component['openCreate']();
component['form'] = { id: 'draft', label: 'BROUILLON', bg: '#fff', color: '#000' };
component['form'] = { id: 'draft', label: 'BROUILLON', bg: '#fff', color: '#000', countsAsCompleted: false };
component['save']();
expect(component['idError']()).not.toBeNull();
expect(storeMock.create).not.toHaveBeenCalled();
});
it('isFormValid returns false when label is empty', () => {
component['form'] = { id: '', label: '', bg: '#fff', color: '#000' };
component['form'] = { id: '', label: '', bg: '#fff', color: '#000', countsAsCompleted: false };
expect(component['isFormValid']()).toBe(false);
});
it('isFormValid returns true when label is set', () => {
component['form'] = { id: '', label: 'TEST', bg: '#fff', color: '#000' };
component['form'] = { id: '', label: 'TEST', bg: '#fff', color: '#000', countsAsCompleted: false };
expect(component['isFormValid']()).toBe(true);
});
});
@@ -24,7 +24,7 @@ describe('StatusesStore', () => {
});
it('loads statuses from localStorage when available', () => {
const saved = [{ id: 'custom', label: 'CUSTOM', bg: '#fff', color: '#000', order: 0 }];
const saved = [{ id: 'custom', label: 'CUSTOM', bg: '#fff', color: '#000', order: 0, countsAsCompleted: false }];
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
TestBed.resetTestingModule();
TestBed.configureTestingModule({});
@@ -62,9 +62,30 @@ describe('StatusesStore', () => {
});
});
describe('isCompleted', () => {
it('returns true for the done status', () => {
expect(store.isCompleted('done')).toBe(true);
});
it('returns false for non-completed statuses', () => {
expect(store.isCompleted('draft')).toBe(false);
expect(store.isCompleted('todo')).toBe(false);
expect(store.isCompleted('in-progress')).toBe(false);
});
it('returns false for an unknown status', () => {
expect(store.isCompleted('unknown')).toBe(false);
});
it('returns true for a custom status with countsAsCompleted true', () => {
store.create({ id: 'abandoned', label: 'ABANDONNÉE', bg: '#f1f5f9', color: '#64748b', countsAsCompleted: true });
expect(store.isCompleted('abandoned')).toBe(true);
});
});
describe('create', () => {
it('adds a new status to the list', () => {
store.create({ id: 'review', label: 'EN REVUE', bg: '#fef9c3', color: '#854d0e' });
store.create({ id: 'review', label: 'EN REVUE', bg: '#fef9c3', color: '#854d0e', countsAsCompleted: false });
const found = store.getById('review');
expect(found).toBeDefined();
expect(found?.label).toBe('EN REVUE');
@@ -72,13 +93,13 @@ describe('StatusesStore', () => {
it('assigns an order greater than existing max', () => {
const maxBefore = Math.max(...store.statuses().map((s) => s.order));
store.create({ id: 'new-one', label: 'NOUVEAU', bg: '#fff', color: '#000' });
store.create({ id: 'new-one', label: 'NOUVEAU', bg: '#fff', color: '#000', countsAsCompleted: false });
const created = store.getById('new-one');
expect(created?.order).toBeGreaterThan(maxBefore);
});
it('persists to localStorage', () => {
store.create({ id: 'saved', label: 'SAUVEGARDÉ', bg: '#fff', color: '#000' });
store.create({ id: 'saved', label: 'SAUVEGARDÉ', bg: '#fff', color: '#000', countsAsCompleted: false });
const raw = localStorage.getItem(STORAGE_KEY);
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
@@ -104,6 +125,11 @@ describe('StatusesStore', () => {
expect(store.getById('todo')?.label).toBe('À FAIRE');
});
it('updates countsAsCompleted', () => {
store.update('draft', { countsAsCompleted: true });
expect(store.getById('draft')?.countsAsCompleted).toBe(true);
});
it('persists changes to localStorage', () => {
store.update('draft', { label: 'UPDATED' });
const raw = localStorage.getItem(STORAGE_KEY);
+10 -5
View File
@@ -6,13 +6,14 @@ export type StatusEntity = {
bg: string;
color: string;
order: number;
countsAsCompleted: boolean;
};
export const DEFAULT_STATUSES: StatusEntity[] = [
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0 },
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1 },
{ id: 'in-progress', label: 'EN COURS', bg: '#ffedd5', color: '#9a3412', order: 2 },
{ id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3 },
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0, countsAsCompleted: false },
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1, countsAsCompleted: false },
{ id: 'in-progress', label: 'EN COURS', bg: '#ffedd5', color: '#9a3412', order: 2, countsAsCompleted: false },
{ id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true },
];
const STORAGE_KEY = 'bonsai_statuses';
@@ -51,13 +52,17 @@ export class StatusesStore {
return this.data().find((s) => s.id === id);
}
isCompleted(statusId: string): boolean {
return this.data().find((s) => s.id === statusId)?.countsAsCompleted ?? false;
}
create(status: Omit<StatusEntity, 'order'>): void {
const maxOrder = this.data().reduce((max, s) => Math.max(max, s.order), -1);
this.data.update((statuses) => [...statuses, { ...status, order: maxOrder + 1 }]);
this.saveToStorage();
}
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color'>>): void {
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color' | 'countsAsCompleted'>>): void {
this.data.update((statuses) =>
statuses.map((s) => (s.id === id ? { ...s, ...changes } : s)),
);
+5 -4
View File
@@ -9,6 +9,7 @@ type StatusForm = {
label: string;
bg: string;
color: string;
countsAsCompleted: boolean;
};
@Component({
@@ -37,7 +38,7 @@ export class Statuses {
}
protected openEdit(status: StatusEntity): void {
this.form = { id: status.id, label: status.label, bg: status.bg, color: status.color };
this.form = { id: status.id, label: status.label, bg: status.bg, color: status.color, countsAsCompleted: status.countsAsCompleted };
this.idError.set(null);
this.editingId.set(status.id);
this.formMode.set('edit');
@@ -56,11 +57,11 @@ export class Statuses {
this.idError.set('Un statut avec cet identifiant existe déjà.');
return;
}
this.statusesStore.create({ id, label: this.form.label.trim(), bg: this.form.bg, color: this.form.color });
this.statusesStore.create({ id, label: this.form.label.trim(), bg: this.form.bg, color: this.form.color, countsAsCompleted: this.form.countsAsCompleted });
} else {
const id = this.editingId();
if (!id) return;
this.statusesStore.update(id, { label: this.form.label.trim(), bg: this.form.bg, color: this.form.color });
this.statusesStore.update(id, { label: this.form.label.trim(), bg: this.form.bg, color: this.form.color, countsAsCompleted: this.form.countsAsCompleted });
}
this.formMode.set(null);
this.editingId.set(null);
@@ -81,6 +82,6 @@ export class Statuses {
}
private emptyForm(): StatusForm {
return { id: '', label: '', bg: '#e2e8f0', color: '#475569' };
return { id: '', label: '', bg: '#e2e8f0', color: '#475569', countsAsCompleted: false };
}
}
+15
View File
@@ -67,6 +67,18 @@
</div>
</div>
<div class="mb-3 form-check">
<input
id="status-counts-as-completed"
class="form-check-input"
type="checkbox"
[(ngModel)]="form.countsAsCompleted"
/>
<label class="form-check-label" for="status-counts-as-completed">
Compte comme terminé (inclus dans le calcul d'avancement)
</label>
</div>
<div class="status-form-actions">
<button class="btn btn-secondary btn-sm" (click)="cancel()">Annuler</button>
<button class="btn btn-primary btn-sm" [disabled]="!isFormValid()" (click)="save()">
@@ -93,6 +105,9 @@
<span class="status-item__position">{{ i + 1 }}</span>
<span class="status-badge" [style.background]="status.bg" [style.color]="status.color">{{ status.label }}</span>
<span class="status-id text-muted">{{ status.id }}</span>
@if (status.countsAsCompleted) {
<span class="status-completed-badge text-success small">✓ compte comme terminé</span>
}
<div class="status-item__actions">
<button
class="btn btn-outline-secondary btn-sm"
+3 -3
View File
@@ -81,14 +81,14 @@ describe('Statuses', () => {
it('saves a new status', () => {
(component as any).openCreate();
(component as any).form = { id: 'test-id', label: 'TEST', bg: '#fff', color: '#000' };
(component as any).form = { id: 'test-id', label: 'TEST', bg: '#fff', color: '#000', countsAsCompleted: false };
(component as any).save();
expect(store.getById('test-id')?.label).toBe('TEST');
});
it('reports duplicate id error', () => {
(component as any).openCreate();
(component as any).form = { id: 'draft', label: 'DRAFT', bg: '#fff', color: '#000' };
(component as any).form = { id: 'draft', label: 'DRAFT', bg: '#fff', color: '#000', countsAsCompleted: false };
(component as any).save();
expect((component as any).idError()).toBeTruthy();
});
@@ -105,7 +105,7 @@ describe('Statuses', () => {
it('saves updated label', () => {
const status = DEFAULT_STATUSES[0];
(component as any).openEdit(status);
(component as any).form = { ...status, label: 'MODIFIÉ' };
(component as any).form = { ...status, label: 'MODIFIÉ', countsAsCompleted: false };
(component as any).save();
expect(store.getById(status.id)?.label).toBe('MODIFIÉ');
});
+24 -3
View File
@@ -80,16 +80,37 @@ describe('StatusesStore', () => {
});
});
describe('isCompleted', () => {
it('returns true for done status', () => {
expect(store.isCompleted('done')).toBe(true);
});
it('returns false for non-completed statuses', () => {
expect(store.isCompleted('draft')).toBe(false);
expect(store.isCompleted('todo')).toBe(false);
expect(store.isCompleted('in-progress')).toBe(false);
});
it('returns false for unknown status', () => {
expect(store.isCompleted('unknown')).toBe(false);
});
it('returns true for a custom status with countsAsCompleted true', () => {
store.create({ id: 'abandoned', label: 'ABANDONNÉE', bg: '#f1f5f9', color: '#64748b', countsAsCompleted: true });
expect(store.isCompleted('abandoned')).toBe(true);
});
});
describe('create', () => {
it('adds a new status with the next order value', () => {
store.create({ id: 'blocked', label: 'BLOQUÉ', bg: '#fee2e2', color: '#991b1b' });
store.create({ id: 'blocked', label: 'BLOQUÉ', bg: '#fee2e2', color: '#991b1b', countsAsCompleted: false });
const added = store.getById('blocked');
expect(added?.label).toBe('BLOQUÉ');
expect(added?.order).toBe(DEFAULT_STATUSES.length);
});
it('persists the new status to localStorage', () => {
store.create({ id: 'custom', label: 'CUSTOM', bg: '#fff', color: '#000' });
store.create({ id: 'custom', label: 'CUSTOM', bg: '#fff', color: '#000', countsAsCompleted: false });
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
expect(stored.some((s: { id: string }) => s.id === 'custom')).toBe(true);
});
@@ -97,7 +118,7 @@ describe('StatusesStore', () => {
describe('update', () => {
it('updates label, bg and color of an existing status', () => {
store.update('draft', { label: 'NOUVEAU', bg: '#fff', color: '#000' });
store.update('draft', { label: 'NOUVEAU', bg: '#fff', color: '#000', countsAsCompleted: false });
const updated = store.getById('draft');
expect(updated?.label).toBe('NOUVEAU');
expect(updated?.bg).toBe('#fff');
+10 -5
View File
@@ -6,13 +6,14 @@ export type StatusEntity = {
bg: string;
color: string;
order: number;
countsAsCompleted: boolean;
};
export const DEFAULT_STATUSES: StatusEntity[] = [
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0 },
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1 },
{ id: 'in-progress', label: 'EN COURS', bg: '#ffedd5', color: '#9a3412', order: 2 },
{ id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3 },
{ id: 'draft', label: 'BROUILLON', bg: '#e2e8f0', color: '#475569', order: 0, countsAsCompleted: false },
{ id: 'todo', label: 'À FAIRE', bg: '#dbeafe', color: '#1d4ed8', order: 1, countsAsCompleted: false },
{ id: 'in-progress', label: 'EN COURS', bg: '#ffedd5', color: '#9a3412', order: 2, countsAsCompleted: false },
{ id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true },
];
const STORAGE_KEY = 'bonsai_statuses';
@@ -27,13 +28,17 @@ export class StatusesStore {
return this.data().find((s) => s.id === id);
}
isCompleted(statusId: string): boolean {
return this.data().find((s) => s.id === statusId)?.countsAsCompleted ?? false;
}
create(status: Omit<StatusEntity, 'order'>): void {
const maxOrder = this.data().reduce((max, s) => Math.max(max, s.order), -1);
this.data.update((list) => [...list, { ...status, order: maxOrder + 1 }]);
this.saveToStorage();
}
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color'>>): void {
update(id: string, changes: Partial<Pick<StatusEntity, 'label' | 'bg' | 'color' | 'countsAsCompleted'>>): void {
this.data.update((list) =>
list.map((s) => (s.id === id ? { ...s, ...changes } : s)),
);
+5 -5
View File
@@ -3,7 +3,7 @@ import { FormsModule } from '@angular/forms';
import { StatusEntity, StatusesStore } from './statuses.store';
type FormMode = 'create' | 'edit';
type StatusForm = { id: string; label: string; bg: string; color: string };
type StatusForm = { id: string; label: string; bg: string; color: string; countsAsCompleted: boolean };
@Component({
selector: 'app-statuses',
@@ -31,7 +31,7 @@ export class Statuses {
}
protected openEdit(status: StatusEntity): void {
this.form = { id: status.id, label: status.label, bg: status.bg, color: status.color };
this.form = { id: status.id, label: status.label, bg: status.bg, color: status.color, countsAsCompleted: status.countsAsCompleted };
this.idError.set(null);
this.editingId.set(status.id);
this.formMode.set('edit');
@@ -50,11 +50,11 @@ export class Statuses {
this.idError.set('Un statut avec cet identifiant existe déjà.');
return;
}
this.statusesStore.create({ id, label: this.form.label.trim(), bg: this.form.bg, color: this.form.color });
this.statusesStore.create({ id, label: this.form.label.trim(), bg: this.form.bg, color: this.form.color, countsAsCompleted: this.form.countsAsCompleted });
} else {
const id = this.editingId();
if (!id) return;
this.statusesStore.update(id, { label: this.form.label.trim(), bg: this.form.bg, color: this.form.color });
this.statusesStore.update(id, { label: this.form.label.trim(), bg: this.form.bg, color: this.form.color, countsAsCompleted: this.form.countsAsCompleted });
}
this.formMode.set(null);
this.editingId.set(null);
@@ -104,6 +104,6 @@ export class Statuses {
}
private emptyForm(): StatusForm {
return { id: '', label: '', bg: '#e2e8f0', color: '#475569' };
return { id: '', label: '', bg: '#e2e8f0', color: '#475569', countsAsCompleted: false };
}
}