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\\); 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 \"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": [
"/var/home/Gato/IdeaProjects/Bonsai-webapp/src/app",
+1
View File
@@ -13,6 +13,7 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
epic: '',
name: 'Test Issue',
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '',
description: '',
@@ -12,6 +12,7 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
epic: '',
name: 'Test Issue',
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '',
description: '',
@@ -171,6 +171,7 @@ export class IssueComments {
assignee: '',
epic: '',
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '',
description: '',
@@ -240,3 +240,22 @@
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;
}
+35 -9
View File
@@ -155,15 +155,41 @@
</div>
<div class="row g-2">
<div class="col-6">
<label class="field-label">Date de début</label>
<input
aria-label="Date de début"
class="form-control form-control-sm"
[class.is-invalid]="!!dateValidationError"
type="date"
[(ngModel)]="issue.startDate"
(blur)="onStartDateBlur()"
/>
<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
aria-label="Date de début"
class="form-control form-control-sm"
[class.is-invalid]="!!dateValidationError"
type="date"
[(ngModel)]="issue.startDate"
(blur)="onStartDateBlur()"
/>
}
</div>
<div class="col-6">
<label class="field-label">Date de fin</label>
@@ -15,6 +15,7 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
epic: '',
name: 'Test Issue',
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '',
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)', () => {
it('sets endDate to startDate when estimatedTime is 8h or less', () => {
(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 { FormsModule } from '@angular/forms';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@@ -56,6 +56,18 @@ export class IssueDetail {
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;
@@ -78,6 +90,10 @@ export class IssueDetail {
'Technical Story',
];
protected get hasDependencies(): boolean {
return this.issue.dependsOnIds.length > 0;
}
protected get dependencyIds(): number[] {
return this.issue.dependsOnIds;
}
@@ -116,6 +132,33 @@ export class IssueDetail {
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 {
return this.issue.estimatedTime;
}
@@ -126,12 +169,14 @@ export class IssueDetail {
}
private recalculateEndDate(): void {
const { startDate, estimatedTime } = this.issue;
if (!startDate || estimatedTime === null || estimatedTime <= 0) {
const effectiveStart =
this.issue.startDateMode === 'calculated' ? this.calculatedStartDate : this.issue.startDate;
const { estimatedTime } = this.issue;
if (!effectiveStart || estimatedTime === null || estimatedTime <= 0) {
this.issue.endDate = '';
return;
}
const start = new Date(startDate);
const start = new Date(effectiveStart);
const extraDays = Math.max(0, Math.ceil(estimatedTime / 8) - 1);
start.setDate(start.getDate() + extraDays);
this.issue.endDate = start.toISOString().split('T')[0];
@@ -197,6 +242,7 @@ export class IssueDetail {
epic: this.issue.name,
name,
startDate: '',
startDateMode: 'calculated',
endDate: '',
dueDate: '',
description: '',
@@ -301,7 +347,7 @@ export class IssueDetail {
if (startDate && endDate && startDate > endDate) {
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) {
const dep = this.issuesStore.getById(depId);
if (dep?.endDate && startDate < dep.endDate) {
@@ -492,6 +538,7 @@ export class IssueDetail {
epic: '',
name: '',
startDate: '',
startDateMode: 'calculated',
endDate: '',
dueDate: '',
description: '',
@@ -515,6 +562,7 @@ export class IssueDetail {
epic: '',
name: '',
startDate: '',
startDateMode: 'calculated',
endDate: '',
dueDate: '',
description: '',
+1
View File
@@ -14,6 +14,7 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
epic: '',
name: 'Test Issue',
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '',
description: '',
+118
View File
@@ -11,6 +11,7 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
epic: '',
name: 'Test Issue',
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '',
description: '',
@@ -183,6 +184,123 @@ describe('IssuesStore', () => {
expect(store.getById(991)?.startDate).toBe('2026-01-01');
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', () => {
+43
View File
@@ -21,6 +21,7 @@ export type IssueEntity = {
epic: string;
name: string;
startDate: string;
startDateMode: 'forced' | 'calculated';
endDate: string;
dueDate: string;
description: string;
@@ -68,6 +69,7 @@ export class IssuesStore {
const { id: _id, ...body } = normalized;
const created = this.normalizeIssue(await firstValueFrom(this.api.create(body)));
this.data.update((issues) => [...issues, created]);
this.recalculateCalculatedIssues();
return created;
} else {
const apiResult = await firstValueFrom(this.api.update(normalized.id, normalized));
@@ -80,6 +82,10 @@ export class IssuesStore {
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);
@@ -88,6 +94,7 @@ export class IssuesStore {
copy[idx] = updated;
return copy;
});
this.recalculateCalculatedIssues();
return updated;
}
}
@@ -99,6 +106,41 @@ export class IssuesStore {
.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(
@@ -113,6 +155,7 @@ export class IssuesStore {
...issue,
type: issue.type ?? 'Story',
startDate: issue.startDate ?? '',
startDateMode: issue.startDateMode === 'calculated' ? 'calculated' : 'forced',
endDate: issue.endDate ?? '',
estimatedTime: issue.estimatedTime ?? null,
dependsOnIds: normalizedDependencies,
@@ -15,6 +15,7 @@ const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
epic: '',
name: 'Test Issue',
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '',
description: '',
@@ -165,6 +165,7 @@ export class MilestoneDetail {
epic: '',
name,
startDate: '',
startDateMode: 'forced',
endDate: '',
dueDate: '',
description: '',