Ajout des dates calculés
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,15 +155,41 @@
|
|||||||
</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">
|
||||||
<input
|
<span class="field-label mb-0">Date de début</span>
|
||||||
aria-label="Date de début"
|
@if (hasDependencies) {
|
||||||
class="form-control form-control-sm"
|
<select
|
||||||
[class.is-invalid]="!!dateValidationError"
|
class="date-mode-select"
|
||||||
type="date"
|
aria-label="Mode date de début"
|
||||||
[(ngModel)]="issue.startDate"
|
[ngModel]="issue.startDateMode"
|
||||||
(blur)="onStartDateBlur()"
|
(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>
|
||||||
<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';
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
Reference in New Issue
Block a user