Ajout des dates calculés

This commit is contained in:
2026-05-30 07:38:08 +02:00
parent b1a114aaa8
commit fb0e853122
13 changed files with 386 additions and 15 deletions
+2 -1
View File
@@ -10,7 +10,8 @@
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); opts=d['projects']['Bonsai-webapp']['architect']['build']['options']; print\\(json.dumps\\({k:opts[k] for k in ['styles','scripts','assets'] if k in opts}, indent=2\\)\\)\")", "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); opts=d['projects']['Bonsai-webapp']['architect']['build']['options']; print\\(json.dumps\\({k:opts[k] for k in ['styles','scripts','assets'] if k in opts}, indent=2\\)\\)\")",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('main',''\\), d.get\\('module',''\\), d.get\\('types',''\\), d.get\\('exports',''\\)\\)\")", "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('main',''\\), d.get\\('module',''\\), d.get\\('types',''\\), d.get\\('exports',''\\)\\)\")",
"Bash(grep -n \"^\\\\s*function \\\\$\\\\|const \\\\$ =\\\\|\\\\$ = \" /var/home/Gato/IdeaProjects/Bonsai-webapp/node_modules/frappe-gantt/dist/frappe-gantt.es.js)", "Bash(grep -n \"^\\\\s*function \\\\$\\\\|const \\\\$ =\\\\|\\\\$ = \" /var/home/Gato/IdeaProjects/Bonsai-webapp/node_modules/frappe-gantt/dist/frappe-gantt.es.js)",
"Bash(grep -n \"function \\\\$\\\\b\\\\|const \\\\$ \" /var/home/Gato/IdeaProjects/Bonsai-webapp/node_modules/frappe-gantt/dist/frappe-gantt.es.js)" "Bash(grep -n \"function \\\\$\\\\b\\\\|const \\\\$ \" /var/home/Gato/IdeaProjects/Bonsai-webapp/node_modules/frappe-gantt/dist/frappe-gantt.es.js)",
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/var/home/Gato/IdeaProjects/Bonsai-webapp/src/app", "/var/home/Gato/IdeaProjects/Bonsai-webapp/src/app",
+1
View File
@@ -13,6 +13,7 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
epic: '', epic: '',
name: 'Test Issue', name: 'Test Issue',
startDate: '', startDate: '',
startDateMode: 'forced',
endDate: '', endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
@@ -12,6 +12,7 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
epic: '', epic: '',
name: 'Test Issue', name: 'Test Issue',
startDate: '', startDate: '',
startDateMode: 'forced',
endDate: '', endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
@@ -171,6 +171,7 @@ export class IssueComments {
assignee: '', assignee: '',
epic: '', epic: '',
startDate: '', startDate: '',
startDateMode: 'forced',
endDate: '', endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
@@ -240,3 +240,22 @@
margin-bottom: 0; margin-bottom: 0;
} }
/* Date mode label row */
.field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.3rem;
}
.date-mode-select {
font-size: 0.7rem;
font-weight: 600;
color: #6b7280;
border: none;
background: transparent;
cursor: pointer;
padding: 0;
appearance: auto;
}
+27 -1
View File
@@ -155,7 +155,32 @@
</div> </div>
<div class="row g-2"> <div class="row g-2">
<div class="col-6"> <div class="col-6">
<label class="field-label">Date de début</label> <div class="field-label-row">
<span class="field-label mb-0">Date de début</span>
@if (hasDependencies) {
<select
class="date-mode-select"
aria-label="Mode date de début"
[ngModel]="issue.startDateMode"
(ngModelChange)="startDateModeValue = $event"
>
<option value="calculated">Calculée</option>
<option value="forced">Forcée</option>
</select>
}
</div>
@if (issue.startDateMode === 'calculated') {
<input
aria-label="Date de début calculée"
class="form-control form-control-sm bg-body-secondary"
type="date"
[ngModel]="calculatedStartDate"
readonly
/>
@if (startDateModeWarning) {
<div class="text-warning small mt-1">{{ startDateModeWarning }}</div>
}
} @else {
<input <input
aria-label="Date de début" aria-label="Date de début"
class="form-control form-control-sm" class="form-control form-control-sm"
@@ -164,6 +189,7 @@
[(ngModel)]="issue.startDate" [(ngModel)]="issue.startDate"
(blur)="onStartDateBlur()" (blur)="onStartDateBlur()"
/> />
}
</div> </div>
<div class="col-6"> <div class="col-6">
<label class="field-label">Date de fin</label> <label class="field-label">Date de fin</label>
@@ -15,6 +15,7 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
epic: '', epic: '',
name: 'Test Issue', name: 'Test Issue',
startDate: '', startDate: '',
startDateMode: 'forced',
endDate: '', endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
@@ -299,6 +300,115 @@ describe('IssueDetail — existing issue', () => {
}); });
}); });
describe('hasDependencies', () => {
it('returns false when dependsOnIds is empty', () => {
(component as any).issue.dependsOnIds = [];
expect((component as any).hasDependencies).toBe(false);
});
it('returns true when dependsOnIds has entries', () => {
(component as any).issue.dependsOnIds = [2];
expect((component as any).hasDependencies).toBe(true);
});
});
describe('calculatedStartDate', () => {
it('returns empty string when there are no dependencies', () => {
(component as any).issue.dependsOnIds = [];
expect((component as any).calculatedStartDate).toBe('');
});
it('returns empty string when dependencies have no endDate', () => {
store.upsert(makeIssue({ id: 10, endDate: '' }));
(component as any).issue.dependsOnIds = [10];
expect((component as any).calculatedStartDate).toBe('');
});
it('returns the endDate of the single dependency', () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-03-01' }));
(component as any).issue.dependsOnIds = [10];
expect((component as any).calculatedStartDate).toBe('2026-03-01');
});
it('returns the latest endDate when multiple dependencies exist', () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-03-01' }));
store.upsert(makeIssue({ id: 11, endDate: '2026-04-15' }));
store.upsert(makeIssue({ id: 12, endDate: '2026-02-20' }));
(component as any).issue.dependsOnIds = [10, 11, 12];
expect((component as any).calculatedStartDate).toBe('2026-04-15');
});
it('ignores dependencies without endDate in the max calculation', () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-03-01' }));
store.upsert(makeIssue({ id: 11, endDate: '' }));
(component as any).issue.dependsOnIds = [10, 11];
expect((component as any).calculatedStartDate).toBe('2026-03-01');
});
});
describe('startDateModeWarning', () => {
it('returns null in forced mode', () => {
(component as any).issue.startDateMode = 'forced';
expect((component as any).startDateModeWarning).toBeNull();
});
it('returns null in calculated mode when a dependency has an endDate', () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-03-01' }));
(component as any).issue.dependsOnIds = [10];
(component as any).issue.startDateMode = 'calculated';
expect((component as any).startDateModeWarning).toBeNull();
});
it('returns a warning message in calculated mode when no dependency has an endDate', () => {
store.upsert(makeIssue({ id: 10, endDate: '' }));
(component as any).issue.dependsOnIds = [10];
(component as any).issue.startDateMode = 'calculated';
expect((component as any).startDateModeWarning).toContain('date de fin');
});
});
describe('startDateModeValue setter', () => {
it('switching to calculated sets startDate from dependency endDate', () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-05-01' }));
(component as any).issue.dependsOnIds = [10];
(component as any).issue.startDateMode = 'forced';
(component as any).startDateModeValue = 'calculated';
expect((component as any).issue.startDate).toBe('2026-05-01');
});
it('switching to calculated recalculates endDate', () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-05-01' }));
(component as any).issue.dependsOnIds = [10];
(component as any).issue.estimatedTime = 8;
(component as any).startDateModeValue = 'calculated';
expect((component as any).issue.endDate).toBe('2026-05-01');
});
it('switching to forced preserves the current startDate', () => {
(component as any).issue.startDate = '2026-04-10';
(component as any).issue.startDateMode = 'calculated';
(component as any).startDateModeValue = 'forced';
expect((component as any).issue.startDate).toBe('2026-04-10');
});
});
describe('dateValidationError — calculated mode', () => {
it('does not flag dependency constraint in calculated mode', () => {
store.upsert(makeIssue({ id: 10, endDate: '2026-02-01' }));
(component as any).issue.dependsOnIds = [10];
(component as any).issue.startDate = '2026-01-01';
(component as any).issue.startDateMode = 'calculated';
expect((component as any).dateValidationError).toBeNull();
});
it('still flags startDate > endDate in calculated mode', () => {
(component as any).issue.startDate = '2026-02-01';
(component as any).issue.endDate = '2026-01-01';
(component as any).issue.startDateMode = 'calculated';
expect((component as any).dateValidationError).toContain('supérieure à la date de fin');
});
});
describe('recalculateEndDate (via estimatedTimeValue setter)', () => { describe('recalculateEndDate (via estimatedTimeValue setter)', () => {
it('sets endDate to startDate when estimatedTime is 8h or less', () => { it('sets endDate to startDate when estimatedTime is 8h or less', () => {
(component as any).issue.startDate = '2026-06-01'; (component as any).issue.startDate = '2026-06-01';
+53 -5
View File
@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core'; import { Component, effect, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@@ -56,6 +56,18 @@ export class IssueDetail {
this.showCreateInEpic = false; this.showCreateInEpic = false;
} }
}); });
effect(() => {
void this.issues();
if (this.issue.startDateMode === 'calculated') {
const newStart = this.calculatedStartDate;
if (this.issue.startDate !== newStart) {
this.issue.startDate = newStart;
this.recalculateEndDate();
this.saveIssue();
}
}
});
} }
protected showAddDependency = false; protected showAddDependency = false;
@@ -78,6 +90,10 @@ export class IssueDetail {
'Technical Story', 'Technical Story',
]; ];
protected get hasDependencies(): boolean {
return this.issue.dependsOnIds.length > 0;
}
protected get dependencyIds(): number[] { protected get dependencyIds(): number[] {
return this.issue.dependsOnIds; return this.issue.dependsOnIds;
} }
@@ -116,6 +132,33 @@ export class IssueDetail {
await this.saveIssue(); await this.saveIssue();
} }
protected get calculatedStartDate(): string {
const dates = this.issue.dependsOnIds
.map((id) => this.issuesStore.getById(id)?.endDate)
.filter((d): d is string => !!d);
if (dates.length === 0) return '';
return dates.reduce((max, d) => (d > max ? d : max));
}
protected get startDateModeWarning(): string | null {
if (this.issue.startDateMode !== 'calculated') return null;
if (!this.calculatedStartDate) return "Aucune dépendance n'a de date de fin définie.";
return null;
}
protected get startDateModeValue(): 'forced' | 'calculated' {
return this.issue.startDateMode;
}
protected set startDateModeValue(mode: 'forced' | 'calculated') {
this.issue.startDateMode = mode;
if (mode === 'calculated') {
this.issue.startDate = this.calculatedStartDate;
}
this.recalculateEndDate();
this.saveIssue();
}
protected get estimatedTimeValue(): number | null { protected get estimatedTimeValue(): number | null {
return this.issue.estimatedTime; return this.issue.estimatedTime;
} }
@@ -126,12 +169,14 @@ export class IssueDetail {
} }
private recalculateEndDate(): void { private recalculateEndDate(): void {
const { startDate, estimatedTime } = this.issue; const effectiveStart =
if (!startDate || estimatedTime === null || estimatedTime <= 0) { this.issue.startDateMode === 'calculated' ? this.calculatedStartDate : this.issue.startDate;
const { estimatedTime } = this.issue;
if (!effectiveStart || estimatedTime === null || estimatedTime <= 0) {
this.issue.endDate = ''; this.issue.endDate = '';
return; return;
} }
const start = new Date(startDate); const start = new Date(effectiveStart);
const extraDays = Math.max(0, Math.ceil(estimatedTime / 8) - 1); const extraDays = Math.max(0, Math.ceil(estimatedTime / 8) - 1);
start.setDate(start.getDate() + extraDays); start.setDate(start.getDate() + extraDays);
this.issue.endDate = start.toISOString().split('T')[0]; this.issue.endDate = start.toISOString().split('T')[0];
@@ -197,6 +242,7 @@ export class IssueDetail {
epic: this.issue.name, epic: this.issue.name,
name, name,
startDate: '', startDate: '',
startDateMode: 'calculated',
endDate: '', endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
@@ -301,7 +347,7 @@ export class IssueDetail {
if (startDate && endDate && startDate > endDate) { if (startDate && endDate && startDate > endDate) {
return 'La date de début ne peut pas être supérieure à la date de fin.'; return 'La date de début ne peut pas être supérieure à la date de fin.';
} }
if (startDate && this.issue.dependsOnIds.length > 0) { if (this.issue.startDateMode !== 'calculated' && startDate && this.issue.dependsOnIds.length > 0) {
for (const depId of this.issue.dependsOnIds) { for (const depId of this.issue.dependsOnIds) {
const dep = this.issuesStore.getById(depId); const dep = this.issuesStore.getById(depId);
if (dep?.endDate && startDate < dep.endDate) { if (dep?.endDate && startDate < dep.endDate) {
@@ -492,6 +538,7 @@ export class IssueDetail {
epic: '', epic: '',
name: '', name: '',
startDate: '', startDate: '',
startDateMode: 'calculated',
endDate: '', endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
@@ -515,6 +562,7 @@ export class IssueDetail {
epic: '', epic: '',
name: '', name: '',
startDate: '', startDate: '',
startDateMode: 'calculated',
endDate: '', endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
+1
View File
@@ -14,6 +14,7 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
epic: '', epic: '',
name: 'Test Issue', name: 'Test Issue',
startDate: '', startDate: '',
startDateMode: 'forced',
endDate: '', endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
+118
View File
@@ -11,6 +11,7 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
epic: '', epic: '',
name: 'Test Issue', name: 'Test Issue',
startDate: '', startDate: '',
startDateMode: 'forced',
endDate: '', endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
@@ -183,6 +184,123 @@ describe('IssuesStore', () => {
expect(store.getById(991)?.startDate).toBe('2026-01-01'); expect(store.getById(991)?.startDate).toBe('2026-01-01');
expect(store.getById(991)?.endDate).toBe('2026-01-31'); expect(store.getById(991)?.endDate).toBe('2026-01-31');
}); });
it('restores startDateMode when the API response omits it', async () => {
const issue = makeIssue({ id: 1, startDateMode: 'calculated' });
const p = store.upsert(issue);
const apiResponse = { ...makeIssue({ id: 1 }), startDateMode: undefined };
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(apiResponse);
await p;
expect(store.getById(1)?.startDateMode).toBe('calculated');
});
it('restores linkedIssueIds in comments when API response omits them', async () => {
const issueWithComment = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: [2, 3] }] });
const apiResponse = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: undefined as any }] });
const p = store.upsert(issueWithComment);
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(apiResponse);
await p;
expect(store.getById(1)?.comments[0].linkedIssueIds).toEqual([2, 3]);
});
it('keeps existing linkedIssueIds in comments when already present in API response', async () => {
const issue = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: [5] }] });
const apiResponse = makeIssue({ id: 1, comments: [{ id: 10, text: 'hello', createdAt: '', updatedAt: null, linkedIssueIds: [5] }] });
const p = store.upsert(issue);
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(apiResponse);
await p;
expect(store.getById(1)?.comments[0].linkedIssueIds).toEqual([5]);
});
});
describe('cascade recalculation of calculated-mode issues', () => {
it('updates startDate of a calculated-mode dependent when its dependency endDate changes', async () => {
await loadWith([
makeIssue({ id: 1, endDate: '2026-06-01' }),
makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-01', endDate: '' }),
]);
const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' }));
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
await p;
expect(store.getById(2)?.startDate).toBe('2026-06-10');
});
it('recalculates endDate of a calculated-mode dependent based on estimatedTime', async () => {
await loadWith([
makeIssue({ id: 1, endDate: '2026-06-01' }),
makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-01', estimatedTime: 16, endDate: '2026-06-02' }),
]);
const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' }));
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
await p;
expect(store.getById(2)?.startDate).toBe('2026-06-10');
expect(store.getById(2)?.endDate).toBe('2026-06-11');
});
it('clears endDate when dependency loses its endDate', async () => {
await loadWith([
makeIssue({ id: 1, endDate: '2026-06-10' }),
makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-10', estimatedTime: 8, endDate: '2026-06-10' }),
]);
const p = store.upsert(makeIssue({ id: 1, endDate: '' }));
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '' }));
await p;
expect(store.getById(2)?.startDate).toBe('');
expect(store.getById(2)?.endDate).toBe('');
});
it('cascades through a chain A → B → C', async () => {
await loadWith([
makeIssue({ id: 1, endDate: '2026-06-01' }),
makeIssue({ id: 2, startDateMode: 'calculated', dependsOnIds: [1], startDate: '2026-06-01', estimatedTime: 8, endDate: '2026-06-01' }),
makeIssue({ id: 3, startDateMode: 'calculated', dependsOnIds: [2], startDate: '2026-06-01', estimatedTime: 16, endDate: '2026-06-02' }),
]);
const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' }));
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
await p;
expect(store.getById(2)?.startDate).toBe('2026-06-10');
expect(store.getById(2)?.endDate).toBe('2026-06-10');
expect(store.getById(3)?.startDate).toBe('2026-06-10');
expect(store.getById(3)?.endDate).toBe('2026-06-11');
});
it('does not affect forced-mode issues', async () => {
await loadWith([
makeIssue({ id: 1, endDate: '2026-06-01' }),
makeIssue({ id: 2, startDateMode: 'forced', dependsOnIds: [1], startDate: '2026-05-01', endDate: '2026-05-15' }),
]);
const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' }));
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
await p;
expect(store.getById(2)?.startDate).toBe('2026-05-01');
expect(store.getById(2)?.endDate).toBe('2026-05-15');
});
it('uses the latest endDate among multiple dependencies', async () => {
await loadWith([
makeIssue({ id: 1, endDate: '2026-06-01' }),
makeIssue({ id: 2, endDate: '2026-06-05' }),
makeIssue({ id: 3, startDateMode: 'calculated', dependsOnIds: [1, 2], startDate: '', estimatedTime: 8, endDate: '' }),
]);
const p = store.upsert(makeIssue({ id: 1, endDate: '2026-06-10' }));
httpMock.expectOne({ method: 'PUT', url: `${API_BASE_URL}/issues/1` }).flush(makeIssue({ id: 1, endDate: '2026-06-10' }));
await p;
expect(store.getById(3)?.startDate).toBe('2026-06-10');
expect(store.getById(3)?.endDate).toBe('2026-06-10');
});
it('recalculates after deleteById removes a dependency', async () => {
await loadWith([
makeIssue({ id: 1, endDate: '2026-06-10' }),
makeIssue({ id: 2, endDate: '2026-06-05' }),
makeIssue({ id: 3, startDateMode: 'calculated', dependsOnIds: [1, 2], startDate: '2026-06-10', estimatedTime: 8, endDate: '2026-06-10' }),
]);
const p = store.deleteById(1);
httpMock.expectOne({ method: 'DELETE', url: `${API_BASE_URL}/issues/1` }).flush(null);
await p;
expect(store.getById(3)?.startDate).toBe('2026-06-05');
expect(store.getById(3)?.endDate).toBe('2026-06-05');
});
}); });
describe('deleteById', () => { describe('deleteById', () => {
+43
View File
@@ -21,6 +21,7 @@ export type IssueEntity = {
epic: string; epic: string;
name: string; name: string;
startDate: string; startDate: string;
startDateMode: 'forced' | 'calculated';
endDate: string; endDate: string;
dueDate: string; dueDate: string;
description: string; description: string;
@@ -68,6 +69,7 @@ export class IssuesStore {
const { id: _id, ...body } = normalized; const { id: _id, ...body } = normalized;
const created = this.normalizeIssue(await firstValueFrom(this.api.create(body))); const created = this.normalizeIssue(await firstValueFrom(this.api.create(body)));
this.data.update((issues) => [...issues, created]); this.data.update((issues) => [...issues, created]);
this.recalculateCalculatedIssues();
return created; return created;
} else { } else {
const apiResult = await firstValueFrom(this.api.update(normalized.id, normalized)); const apiResult = await firstValueFrom(this.api.update(normalized.id, normalized));
@@ -80,6 +82,10 @@ export class IssuesStore {
return { ...c, linkedIssueIds: sent?.linkedIssueIds ?? [] }; 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); const updated = this.normalizeIssue(apiResult);
this.data.update((issues) => { this.data.update((issues) => {
const idx = issues.findIndex((i) => i.id === normalized.id); const idx = issues.findIndex((i) => i.id === normalized.id);
@@ -88,6 +94,7 @@ export class IssuesStore {
copy[idx] = updated; copy[idx] = updated;
return copy; return copy;
}); });
this.recalculateCalculatedIssues();
return updated; return updated;
} }
} }
@@ -99,6 +106,41 @@ export class IssuesStore {
.filter((i) => i.id !== id) .filter((i) => i.id !== id)
.map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== 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( private normalizeIssue(
@@ -113,6 +155,7 @@ export class IssuesStore {
...issue, ...issue,
type: issue.type ?? 'Story', type: issue.type ?? 'Story',
startDate: issue.startDate ?? '', startDate: issue.startDate ?? '',
startDateMode: issue.startDateMode === 'calculated' ? 'calculated' : 'forced',
endDate: issue.endDate ?? '', endDate: issue.endDate ?? '',
estimatedTime: issue.estimatedTime ?? null, estimatedTime: issue.estimatedTime ?? null,
dependsOnIds: normalizedDependencies, dependsOnIds: normalizedDependencies,
@@ -15,6 +15,7 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
epic: '', epic: '',
name: 'Test Issue', name: 'Test Issue',
startDate: '', startDate: '',
startDateMode: 'forced',
endDate: '', endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',
@@ -165,6 +165,7 @@ export class MilestoneDetail {
epic: '', epic: '',
name, name,
startDate: '', startDate: '',
startDateMode: 'forced',
endDate: '', endDate: '',
dueDate: '', dueDate: '',
description: '', description: '',