import { Injectable, inject, signal } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { IssuesApiService } from './issues-api.service'; export type IssueStatus = string; export type IssuePriority = 'TRES_FAIBLE' | 'BASSE' | 'MOYENNE' | 'HAUTE' | 'TRES_HAUTE'; export type IssueType = 'Epic' | 'Bug' | 'Study' | 'Story' | 'Task' | 'Technical Story'; export type IssueComment = { id: number; text: string; createdAt: string; updatedAt: string | null; linkedIssueIds: number[]; }; export type IssueEntity = { id: number; type: IssueType; assignee: string; epic: string; name: string; startDate: string; startDateMode: 'forced' | 'calculated'; endDate: string; dueDate: string; description: string; estimatedTime: number | null; dependsOnIds: number[]; comments: IssueComment[]; priority: IssuePriority; status: IssueStatus; progress: number; }; @Injectable({ providedIn: 'root' }) export class IssuesStore { private readonly api = inject(IssuesApiService); private readonly data = signal([]); private currentProjectId: number | null = null; readonly loading = signal(false); readonly loaded = signal(false); readonly issues = this.data.asReadonly(); getById(id: number): IssueEntity | undefined { return this.data().find((i) => i.id === id); } getNextId(): number { const ids = this.data().map((i) => i.id); return ids.length === 0 ? 1 : Math.max(...ids) + 1; } async load(projectId: number): Promise { if (this.loaded() && this.currentProjectId === projectId) return; this.currentProjectId = projectId; this.loaded.set(false); this.loading.set(true); try { const issues = await firstValueFrom(this.api.getAll(projectId)); this.data.set(issues.map((i) => this.normalizeIssue(i))); this.loaded.set(true); } finally { this.loading.set(false); } } async upsert(issue: IssueEntity): Promise { const normalized = this.normalizeIssue(issue); if (!normalized.id) { const { id: _id, ...body } = normalized; const created = this.normalizeIssue(await firstValueFrom(this.api.create(this.currentProjectId!, body))); this.data.update((issues) => [...issues, created]); this.recalculateCalculatedIssues(); return created; } else { const apiResult = await firstValueFrom(this.api.update(this.currentProjectId!, normalized.id, normalized)); // L'API ne retourne pas linkedIssueIds dans les commentaires : on le restaure // depuis les données envoyées pour ne pas perdre les liens. if (Array.isArray(apiResult.comments) && Array.isArray(normalized.comments)) { apiResult.comments = apiResult.comments.map((c: IssueComment) => { if (Array.isArray(c.linkedIssueIds)) return c; const sent = normalized.comments.find((nc) => nc.id === c.id); 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); if (idx === -1) return issues; const copy = [...issues]; copy[idx] = updated; return copy; }); this.recalculateCalculatedIssues(); return updated; } } async deleteById(id: number): Promise { await firstValueFrom(this.api.remove(this.currentProjectId!, id)); this.data.update((issues) => issues .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( issue: Partial & { dependsOnId?: number | null }, ): IssueEntity { const legacyDependency = typeof issue.dependsOnId === 'number' ? [issue.dependsOnId] : []; const normalizedDependencies = Array.isArray(issue.dependsOnIds) ? issue.dependsOnIds.filter((value): value is number => typeof value === 'number') : legacyDependency; 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) ? issue.comments.map((c) => ({ ...c, linkedIssueIds: Array.isArray(c.linkedIssueIds) ? c.linkedIssueIds : [], })) : [], } as IssueEntity; } }