From 8a196c49ae54eb8a6aba361f38ba435479e0762d Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 1 Jul 2026 14:01:36 +1000 Subject: [PATCH] PM-5447: Preserve started phase schedule shifts What was broken Switching an already launched development challenge from scheduled launch to immediately could still fail with the non-Design phase shortening error when an open phase already had an actualStartDate. Root cause (if identifiable) The previous fix validated recalculated phase dates, but started phases still ignored the requested scheduledStartDate during recalculation. When the UI submitted a whole phase window shifted earlier with the same duration, the helper applied the new end date against the old start date and misclassified the shift as a duration reduction. What was changed Carry the requested scheduled start date through phase recalculation for phases that have started but not ended, and remove that temporary field before returning persisted phase data. Any added/updated tests Added a phase-helper regression test for a started non-Design phase moving earlier with unchanged duration. --- src/common/phase-helper.js | 16 ++++++++++-- test/unit/phase-helper.test.js | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/common/phase-helper.js b/src/common/phase-helper.js index 8d12b6a..7e906f9 100644 --- a/src/common/phase-helper.js +++ b/src/common/phase-helper.js @@ -404,8 +404,18 @@ class ChallengePhaseHelper { ...phase, predecessor: resolvedPredecessor, description: phaseDefinition.description, + requestedScheduledStartDate: _.get(newPhase, "scheduledStartDate"), requestedScheduledEndDate: _.get(newPhase, "scheduledEndDate"), }; + if ( + _.isNil(updatedPhase.actualEndDate) && + !_.isNil(updatedPhase.actualStartDate) && + !_.isNil(updatedPhase.requestedScheduledStartDate) + ) { + updatedPhase.scheduledStartDate = moment(updatedPhase.requestedScheduledStartDate) + .toDate() + .toISOString(); + } if (updatedPhase.name === "Post-Mortem") { updatedPhase.predecessor = "a93544bc-c165-4af4-b55e-18f3593b457a"; } @@ -414,7 +424,7 @@ class ChallengePhaseHelper { } if (_.isNil(updatedPhase.predecessor)) { let scheduledStartDate = _.defaultTo( - _.get(newPhase, "scheduledStartDate"), + updatedPhase.requestedScheduledStartDate, updatedPhase.scheduledStartDate ); if ( @@ -485,7 +495,9 @@ class ChallengePhaseHelper { recalculateScheduledEndDate(phase); } validateRecalculatedPhaseSchedules(challengePhasesOrdered, updatedPhases, options); - return _.map(updatedPhases, (phase) => _.omit(phase, "requestedScheduledEndDate")); + return _.map(updatedPhases, (phase) => + _.omit(phase, ["requestedScheduledStartDate", "requestedScheduledEndDate"]) + ); } handlePhasesAfterCancelling(phases) { diff --git a/test/unit/phase-helper.test.js b/test/unit/phase-helper.test.js index 635c94e..d5c9d7e 100644 --- a/test/unit/phase-helper.test.js +++ b/test/unit/phase-helper.test.js @@ -436,6 +436,53 @@ describe('phase helper unit tests', () => { updatedPhases[0].duration.should.equal(duration) }) + it('allows started non-Design phase schedule to move earlier when duration is unchanged', async () => { + const registrationPhaseId = 'development-registration-phase' + const currentRegistrationStartDate = '2099-05-26T05:14:00.000Z' + const currentRegistrationEndDate = '2099-05-31T05:14:00.000Z' + const requestedRegistrationStartDate = '2099-05-25T05:14:00.000Z' + const requestedRegistrationEndDate = '2099-05-30T05:14:00.000Z' + const duration = 5 * 24 * 60 * 60 + + stubPhaseLookups( + [{ id: registrationPhaseId, name: 'Registration', description: 'Registration phase' }], + [{ phaseId: registrationPhaseId, defaultDuration: duration }] + ) + + const updatedPhases = await phaseHelper.populatePhasesForChallengeUpdate( + [ + { + duration, + isOpen: true, + name: 'Registration', + phaseId: registrationPhaseId, + actualStartDate: requestedRegistrationStartDate, + scheduledStartDate: currentRegistrationStartDate, + scheduledEndDate: currentRegistrationEndDate + } + ], + [ + { + duration, + phaseId: registrationPhaseId, + scheduledStartDate: requestedRegistrationStartDate, + scheduledEndDate: requestedRegistrationEndDate + } + ], + 'timeline-template-id', + false, + { + allowActivePhaseShortening: false, + preventPhaseShortening: true + } + ) + + updatedPhases[0].actualStartDate.should.equal(requestedRegistrationStartDate) + updatedPhases[0].scheduledStartDate.should.equal(requestedRegistrationStartDate) + updatedPhases[0].scheduledEndDate.should.equal(requestedRegistrationEndDate) + updatedPhases[0].duration.should.equal(duration) + }) + it('allows active non-Design dependent phases to move earlier when duration is unchanged', async () => { const registrationPhaseId = 'development-registration-phase' const reviewPhaseId = 'development-review-phase'