Files
Bonsai-webapp/src/app/issues/issues.store.ts
T
2026-05-31 14:36:12 +02:00

174 lines
6.1 KiB
TypeScript

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<IssueEntity[]>([]);
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<void> {
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<IssueEntity> {
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<void> {
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<IssueEntity> & { 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;
}
}