fe1c346eac
Signed-off-by: Gato <cedric@goutailler-olivier.fr>
174 lines
6.1 KiB
TypeScript
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;
|
|
}
|
|
}
|