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 496f704..bc0750e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -15,7 +15,9 @@ ], "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/src/app/dashboard/dashboard.spec.ts b/src/app/dashboard/dashboard.spec.ts index 56d56f7..60bbf6d 100644 --- a/src/app/dashboard/dashboard.spec.ts +++ b/src/app/dashboard/dashboard.spec.ts @@ -239,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: '', startDate: '', endDate: '', 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); }); @@ -248,7 +248,7 @@ describe('Dashboard', () => { makeIssue({ id: 1, status: 'done' }), makeIssue({ id: 2, status: 'todo' }), ]); - milestonesStore.seed([{ id: 1, name: 'Active', description: '', startDate: '', endDate: '', 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-detail/issue-detail.spec.ts b/src/app/issues/issue-detail/issue-detail.spec.ts index 9dc5dcf..8ab90e4 100644 --- a/src/app/issues/issue-detail/issue-detail.spec.ts +++ b/src/app/issues/issue-detail/issue-detail.spec.ts @@ -93,6 +93,7 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit endDate: '', dueDate: '', issueIds: [], + dependsOnIds: [], ...overrides, }); diff --git a/src/app/issues/issues.spec.ts b/src/app/issues/issues.spec.ts index 1657d29..4fb956e 100644 --- a/src/app/issues/issues.spec.ts +++ b/src/app/issues/issues.spec.ts @@ -100,6 +100,7 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit endDate: '', dueDate: '', issueIds: [], + dependsOnIds: [], ...overrides, }); 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 9c786d1..011485d 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.html +++ b/src/app/milestones/milestone-detail/milestone-detail.html @@ -132,6 +132,40 @@ + +@if (!isNewRoute) { +
+
Dépendances
+
+ @if (hasDependencies) { +
+ @for (depId of dependencyIds; track depId) { + + #{{ depId }} + {{ resolveDependency(depId)?.name || 'Sans nom' }} + + + } +
+ } + @if (showAddDependency) { +
+ + + +
+ } @else { + + } +
+
+} +
diff --git a/src/app/milestones/milestone-detail/milestone-detail.spec.ts b/src/app/milestones/milestone-detail/milestone-detail.spec.ts index 58c0e95..de8d458 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.spec.ts +++ b/src/app/milestones/milestone-detail/milestone-detail.spec.ts @@ -36,6 +36,7 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit endDate: '', dueDate: '', issueIds: [], + dependsOnIds: [], ...overrides, }); @@ -501,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); diff --git a/src/app/milestones/milestone-detail/milestone-detail.ts b/src/app/milestones/milestone-detail/milestone-detail.ts index 5e15570..bcf58d6 100644 --- a/src/app/milestones/milestone-detail/milestone-detail.ts +++ b/src/app/milestones/milestone-detail/milestone-detail.ts @@ -35,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(() => { @@ -60,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)); } @@ -269,9 +312,9 @@ export class MilestoneDetail { private buildMilestone(): MilestoneEntity { if (this.route.snapshot.routeConfig?.path === 'milestones/new') { - return { id: 0, name: '', description: '', startDate: '', endDate: '', 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: '', startDate: '', endDate: '', 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 fcac7e5..2eb67e1 100644 --- a/src/app/milestones/milestones-api.service.spec.ts +++ b/src/app/milestones/milestones-api.service.spec.ts @@ -14,6 +14,7 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit endDate: '', dueDate: '', issueIds: [], + dependsOnIds: [], ...overrides, }); @@ -45,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: '', startDate: '', endDate: '', 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.store.spec.ts b/src/app/milestones/milestones.store.spec.ts index b54499f..6ddff4c 100644 --- a/src/app/milestones/milestones.store.spec.ts +++ b/src/app/milestones/milestones.store.spec.ts @@ -13,6 +13,7 @@ const makeMilestone = (overrides: Partial = {}): MilestoneEntit endDate: '', dueDate: '', issueIds: [], + dependsOnIds: [], ...overrides, }); @@ -144,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 47393e5..59f13fe 100644 --- a/src/app/milestones/milestones.store.ts +++ b/src/app/milestones/milestones.store.ts @@ -10,6 +10,7 @@ export type MilestoneEntity = { endDate: string; dueDate: string; issueIds: number[]; + dependsOnIds: number[]; }; @Injectable({ providedIn: 'root' }) @@ -75,6 +76,9 @@ export class MilestonesStore { 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') + : [], }; } }