mirror of
https://github.com/Unleash/unleash.git
synced 2025-11-10 01:19:53 +01:00
feat: add milestone progression UI with projected start times (#10790)
This commit is contained in:
parent
331d00b329
commit
bc740bbe2f
@ -96,7 +96,12 @@ const StartMilestone: FC<{
|
|||||||
</div>
|
</div>
|
||||||
</ChangeItemWrapper>
|
</ChangeItemWrapper>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<ReleasePlanMilestone readonly milestone={newMilestone} />
|
<ReleasePlanMilestone
|
||||||
|
readonly
|
||||||
|
milestone={newMilestone}
|
||||||
|
allMilestones={releasePlan.milestones}
|
||||||
|
activeMilestoneId={releasePlan.activeMilestoneId}
|
||||||
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel variant='diff'>
|
<TabPanel variant='diff'>
|
||||||
<EventDiff
|
<EventDiff
|
||||||
|
|||||||
@ -371,6 +371,8 @@ export const ReleasePlan = ({
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
environment={environment}
|
environment={environment}
|
||||||
onUpdate={refetch}
|
onUpdate={refetch}
|
||||||
|
allMilestones={milestones}
|
||||||
|
activeMilestoneId={activeMilestoneId}
|
||||||
/>
|
/>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={isNotLastMilestone}
|
condition={isNotLastMilestone}
|
||||||
|
|||||||
@ -0,0 +1,86 @@
|
|||||||
|
import { styled } from '@mui/material';
|
||||||
|
import HourglassEmptyOutlinedIcon from '@mui/icons-material/HourglassEmptyOutlined';
|
||||||
|
import type { IReleasePlanMilestone } from 'interfaces/releasePlans';
|
||||||
|
import { formatDateYMDHMS } from 'utils/formatDate';
|
||||||
|
import { isToday, isTomorrow, format } from 'date-fns';
|
||||||
|
import { calculateMilestoneStartTime } from '../utils/calculateMilestoneStartTime.ts';
|
||||||
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
|
|
||||||
|
const formatSmartDate = (date: Date): string => {
|
||||||
|
const timeString = format(date, 'HH:mm');
|
||||||
|
|
||||||
|
if (isToday(date)) {
|
||||||
|
return `today at ${timeString}`;
|
||||||
|
}
|
||||||
|
if (isTomorrow(date)) {
|
||||||
|
return `tomorrow at ${timeString}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other dates, show full date with time
|
||||||
|
return formatDateYMDHMS(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StyledTimeContainer = styled('span')(({ theme }) => ({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(0.75),
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
fontWeight: theme.typography.fontWeightRegular,
|
||||||
|
backgroundColor: theme.palette.background.elevation1,
|
||||||
|
padding: theme.spacing(0.5, 1),
|
||||||
|
borderRadius: theme.shape.borderRadiusLarge,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledIcon = styled(HourglassEmptyOutlinedIcon)(({ theme }) => ({
|
||||||
|
fontSize: 18,
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IMilestoneNextStartTimeProps {
|
||||||
|
milestone: IReleasePlanMilestone;
|
||||||
|
allMilestones: IReleasePlanMilestone[];
|
||||||
|
activeMilestoneId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MilestoneNextStartTime = ({
|
||||||
|
milestone,
|
||||||
|
allMilestones,
|
||||||
|
activeMilestoneId,
|
||||||
|
}: IMilestoneNextStartTimeProps) => {
|
||||||
|
const milestoneProgressionEnabled = useUiFlag('milestoneProgression');
|
||||||
|
|
||||||
|
if (!milestoneProgressionEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIndex = allMilestones.findIndex(
|
||||||
|
(milestone) => milestone.id === activeMilestoneId,
|
||||||
|
);
|
||||||
|
const currentIndex = allMilestones.findIndex((m) => m.id === milestone.id);
|
||||||
|
|
||||||
|
const isActiveMilestone = milestone.id === activeMilestoneId;
|
||||||
|
const isBehindActiveMilestone =
|
||||||
|
activeIndex !== -1 && currentIndex !== -1 && currentIndex < activeIndex;
|
||||||
|
|
||||||
|
if (isActiveMilestone || isBehindActiveMilestone) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectedStartTime = calculateMilestoneStartTime(
|
||||||
|
allMilestones,
|
||||||
|
milestone.id,
|
||||||
|
activeMilestoneId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = projectedStartTime
|
||||||
|
? `Starting ${formatSmartDate(projectedStartTime)}`
|
||||||
|
: 'Waiting to start';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledTimeContainer>
|
||||||
|
<StyledIcon />
|
||||||
|
{text}
|
||||||
|
</StyledTimeContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
type MilestoneStatus,
|
type MilestoneStatus,
|
||||||
} from './ReleasePlanMilestoneStatus.tsx';
|
} from './ReleasePlanMilestoneStatus.tsx';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { MilestoneNextStartTime } from './MilestoneNextStartTime.tsx';
|
||||||
|
|
||||||
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
|
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
|
||||||
import { StrategyItem } from '../../FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx';
|
import { StrategyItem } from '../../FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx';
|
||||||
@ -105,6 +106,8 @@ interface IReleasePlanMilestoneProps {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
environment?: string;
|
environment?: string;
|
||||||
onUpdate?: () => void;
|
onUpdate?: () => void;
|
||||||
|
allMilestones: IReleasePlanMilestone[];
|
||||||
|
activeMilestoneId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReleasePlanMilestone = ({
|
export const ReleasePlanMilestone = ({
|
||||||
@ -119,6 +122,8 @@ export const ReleasePlanMilestone = ({
|
|||||||
projectId,
|
projectId,
|
||||||
environment,
|
environment,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
allMilestones,
|
||||||
|
activeMilestoneId,
|
||||||
}: IReleasePlanMilestoneProps) => {
|
}: IReleasePlanMilestoneProps) => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
@ -134,6 +139,15 @@ export const ReleasePlanMilestone = ({
|
|||||||
{(!readonly && onStartMilestone) ||
|
{(!readonly && onStartMilestone) ||
|
||||||
(status === 'active' && milestone.startedAt) ? (
|
(status === 'active' && milestone.startedAt) ? (
|
||||||
<StyledStatusRow>
|
<StyledStatusRow>
|
||||||
|
{!readonly && (
|
||||||
|
<MilestoneNextStartTime
|
||||||
|
milestone={milestone}
|
||||||
|
allMilestones={allMilestones}
|
||||||
|
activeMilestoneId={
|
||||||
|
activeMilestoneId
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!readonly && onStartMilestone && (
|
{!readonly && onStartMilestone && (
|
||||||
<ReleasePlanMilestoneStatus
|
<ReleasePlanMilestoneStatus
|
||||||
status={status}
|
status={status}
|
||||||
@ -193,6 +207,13 @@ export const ReleasePlanMilestone = ({
|
|||||||
{(!readonly && onStartMilestone) ||
|
{(!readonly && onStartMilestone) ||
|
||||||
(status === 'active' && milestone.startedAt) ? (
|
(status === 'active' && milestone.startedAt) ? (
|
||||||
<StyledStatusRow>
|
<StyledStatusRow>
|
||||||
|
{!readonly && (
|
||||||
|
<MilestoneNextStartTime
|
||||||
|
milestone={milestone}
|
||||||
|
allMilestones={allMilestones}
|
||||||
|
activeMilestoneId={activeMilestoneId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!readonly && onStartMilestone && (
|
{!readonly && onStartMilestone && (
|
||||||
<ReleasePlanMilestoneStatus
|
<ReleasePlanMilestoneStatus
|
||||||
status={status}
|
status={status}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { styled } from '@mui/material';
|
|||||||
import PlayCircleIcon from '@mui/icons-material/PlayCircle';
|
import PlayCircleIcon from '@mui/icons-material/PlayCircle';
|
||||||
import PauseCircleIcon from '@mui/icons-material/PauseCircle';
|
import PauseCircleIcon from '@mui/icons-material/PauseCircle';
|
||||||
import TripOriginIcon from '@mui/icons-material/TripOrigin';
|
import TripOriginIcon from '@mui/icons-material/TripOrigin';
|
||||||
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
|
|
||||||
export type MilestoneStatus = 'not-started' | 'active' | 'paused' | 'completed';
|
export type MilestoneStatus = 'not-started' | 'active' | 'paused' | 'completed';
|
||||||
|
|
||||||
@ -67,30 +68,49 @@ interface IReleasePlanMilestoneStatusProps {
|
|||||||
onStartMilestone: () => void;
|
onStartMilestone: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStatusText = (
|
||||||
|
status: MilestoneStatus,
|
||||||
|
progressionsEnabled: boolean,
|
||||||
|
): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return 'Running';
|
||||||
|
case 'paused':
|
||||||
|
return 'Paused (disabled in environment)';
|
||||||
|
case 'completed':
|
||||||
|
return progressionsEnabled ? 'Start now' : 'Restart';
|
||||||
|
case 'not-started':
|
||||||
|
return progressionsEnabled ? 'Start now' : 'Start';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: MilestoneStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return <TripOriginIcon />;
|
||||||
|
case 'paused':
|
||||||
|
return <PauseCircleIcon />;
|
||||||
|
default:
|
||||||
|
return <PlayCircleIcon />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const ReleasePlanMilestoneStatus = ({
|
export const ReleasePlanMilestoneStatus = ({
|
||||||
status,
|
status,
|
||||||
onStartMilestone,
|
onStartMilestone,
|
||||||
}: IReleasePlanMilestoneStatusProps) => {
|
}: IReleasePlanMilestoneStatusProps) => {
|
||||||
const statusText =
|
const milestoneProgressionsEnabled = useUiFlag('milestoneProgression');
|
||||||
status === 'active'
|
|
||||||
? 'Running'
|
|
||||||
: status === 'paused'
|
|
||||||
? 'Paused (disabled in environment)'
|
|
||||||
: status === 'completed'
|
|
||||||
? 'Restart'
|
|
||||||
: 'Start';
|
|
||||||
|
|
||||||
const statusIcon =
|
|
||||||
status === 'active' ? (
|
|
||||||
<TripOriginIcon />
|
|
||||||
) : status === 'paused' ? (
|
|
||||||
<PauseCircleIcon />
|
|
||||||
) : (
|
|
||||||
<PlayCircleIcon />
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const statusText = getStatusText(status, milestoneProgressionsEnabled);
|
||||||
|
const statusIcon = getStatusIcon(status);
|
||||||
const disabled = status === 'active' || status === 'paused';
|
const disabled = status === 'active' || status === 'paused';
|
||||||
|
|
||||||
|
// Hide the play icon when progressions are enabled and milestone is not active/paused
|
||||||
|
const shouldShowIcon =
|
||||||
|
status === 'active' ||
|
||||||
|
status === 'paused' ||
|
||||||
|
!milestoneProgressionsEnabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledStatusButton
|
<StyledStatusButton
|
||||||
status={status}
|
status={status}
|
||||||
@ -100,7 +120,7 @@ export const ReleasePlanMilestoneStatus = ({
|
|||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{statusIcon}
|
{shouldShowIcon && statusIcon}
|
||||||
{statusText}
|
{statusText}
|
||||||
</StyledStatusButton>
|
</StyledStatusButton>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,133 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { calculateMilestoneStartTime } from './calculateMilestoneStartTime.js';
|
||||||
|
import type { IReleasePlanMilestone } from 'interfaces/releasePlans';
|
||||||
|
|
||||||
|
const createMilestone = (
|
||||||
|
id: string,
|
||||||
|
startedAt: string | null = null,
|
||||||
|
intervalMinutes?: number,
|
||||||
|
): IReleasePlanMilestone => ({
|
||||||
|
id,
|
||||||
|
name: `Milestone ${id}`,
|
||||||
|
startedAt,
|
||||||
|
transitionCondition: intervalMinutes ? { intervalMinutes } : undefined,
|
||||||
|
strategies: [],
|
||||||
|
releasePlanDefinitionId: 'test-plan',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateMilestoneStartTime', () => {
|
||||||
|
const baseTime = '2024-01-01T10:00:00.000Z';
|
||||||
|
const ONE_HOUR_IN_MINUTES = 60;
|
||||||
|
const THIRTY_MINUTES = 30;
|
||||||
|
const FIFTEEN_MINUTES = 15;
|
||||||
|
const TWO_HOURS_IN_MINUTES = 120;
|
||||||
|
const FOUR_HOURS_IN_MINUTES = 240;
|
||||||
|
const NO_INTERVAL = 0;
|
||||||
|
|
||||||
|
it('returns null for invalid milestone ID', () => {
|
||||||
|
const milestones = [
|
||||||
|
createMilestone('1', baseTime, ONE_HOUR_IN_MINUTES),
|
||||||
|
];
|
||||||
|
expect(calculateMilestoneStartTime(milestones, 'invalid')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when first milestone has not started', () => {
|
||||||
|
const milestones = [
|
||||||
|
createMilestone('1', null, ONE_HOUR_IN_MINUTES),
|
||||||
|
createMilestone('2', null, ONE_HOUR_IN_MINUTES),
|
||||||
|
];
|
||||||
|
expect(calculateMilestoneStartTime(milestones, '2')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates cascading milestone times through the chain', () => {
|
||||||
|
const milestones = [
|
||||||
|
createMilestone('1', baseTime, ONE_HOUR_IN_MINUTES), // +60 min = 11:00
|
||||||
|
createMilestone('2', null, THIRTY_MINUTES), // +30 min = 11:30
|
||||||
|
createMilestone('3', null, FIFTEEN_MINUTES), // +15 min = 11:45
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(calculateMilestoneStartTime(milestones, '1')).toEqual(
|
||||||
|
new Date(baseTime),
|
||||||
|
);
|
||||||
|
expect(calculateMilestoneStartTime(milestones, '2')).toEqual(
|
||||||
|
new Date('2024-01-01T11:00:00.000Z'),
|
||||||
|
);
|
||||||
|
expect(calculateMilestoneStartTime(milestones, '3')).toEqual(
|
||||||
|
new Date('2024-01-01T11:30:00.000Z'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses actual start time when milestone is manually started', () => {
|
||||||
|
const milestones = [
|
||||||
|
createMilestone('1', baseTime, ONE_HOUR_IN_MINUTES),
|
||||||
|
createMilestone('2', '2024-01-01T12:00:00.000Z', THIRTY_MINUTES), // Manually started
|
||||||
|
createMilestone('3', null),
|
||||||
|
];
|
||||||
|
expect(calculateMilestoneStartTime(milestones, '3')).toEqual(
|
||||||
|
new Date('2024-01-01T12:30:00.000Z'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when milestone chain is broken', () => {
|
||||||
|
const milestones = [
|
||||||
|
createMilestone('1', baseTime, ONE_HOUR_IN_MINUTES),
|
||||||
|
createMilestone('2', null), // No transition condition
|
||||||
|
createMilestone('3', null, FIFTEEN_MINUTES),
|
||||||
|
];
|
||||||
|
expect(calculateMilestoneStartTime(milestones, '3')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles typical release plan with manual promotion', () => {
|
||||||
|
const milestones = [
|
||||||
|
createMilestone(
|
||||||
|
'alpha',
|
||||||
|
'2024-01-01T09:00:00.000Z',
|
||||||
|
TWO_HOURS_IN_MINUTES,
|
||||||
|
),
|
||||||
|
createMilestone(
|
||||||
|
'beta',
|
||||||
|
'2024-01-01T10:00:00.000Z',
|
||||||
|
FOUR_HOURS_IN_MINUTES,
|
||||||
|
),
|
||||||
|
createMilestone('prod', null, NO_INTERVAL),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(calculateMilestoneStartTime(milestones, 'prod')).toEqual(
|
||||||
|
new Date('2024-01-01T14:00:00.000Z'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates from active milestone when provided', () => {
|
||||||
|
const milestones = [
|
||||||
|
createMilestone(
|
||||||
|
'1',
|
||||||
|
'2024-01-01T14:00:00.000Z',
|
||||||
|
ONE_HOUR_IN_MINUTES,
|
||||||
|
),
|
||||||
|
createMilestone('2', '2024-01-01T15:30:00.000Z', THIRTY_MINUTES),
|
||||||
|
createMilestone('3', '2024-01-01T11:30:00.000Z', FIFTEEN_MINUTES), // Old stale start time
|
||||||
|
];
|
||||||
|
|
||||||
|
// When milestone 2 is active, calculate milestone 3 from milestone 2's time
|
||||||
|
expect(calculateMilestoneStartTime(milestones, '3', '2')).toEqual(
|
||||||
|
new Date('2024-01-01T16:00:00.000Z'), // 15:30 + 30 min
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses actual start time when manually progressing to next milestone', () => {
|
||||||
|
const milestones = [
|
||||||
|
createMilestone(
|
||||||
|
'1',
|
||||||
|
'2024-01-01T10:00:00.000Z',
|
||||||
|
ONE_HOUR_IN_MINUTES,
|
||||||
|
),
|
||||||
|
createMilestone('2', '2024-01-01T11:00:00.000Z', THIRTY_MINUTES), // Manually started at 11:00
|
||||||
|
createMilestone('3', null, FIFTEEN_MINUTES),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Milestone 3 should calculate from milestone 2's actual start time
|
||||||
|
expect(calculateMilestoneStartTime(milestones, '3')).toEqual(
|
||||||
|
new Date('2024-01-01T11:30:00.000Z'), // 11:00 + 30 min
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
import type { IReleasePlanMilestone } from 'interfaces/releasePlans';
|
||||||
|
import { addMinutes } from 'date-fns';
|
||||||
|
|
||||||
|
const parseStartTime = (startedAt: string | null | undefined): Date | null => {
|
||||||
|
if (!startedAt) return null;
|
||||||
|
|
||||||
|
const date = new Date(startedAt);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIntervalMinutes = (
|
||||||
|
milestone: IReleasePlanMilestone,
|
||||||
|
): number | null => {
|
||||||
|
const intervalMinutes = milestone.transitionCondition?.intervalMinutes;
|
||||||
|
|
||||||
|
if (!intervalMinutes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return intervalMinutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findMostRecentStartedMilestone = (
|
||||||
|
milestones: IReleasePlanMilestone[],
|
||||||
|
targetIndex: number,
|
||||||
|
): { index: number; startTime: Date } | null => {
|
||||||
|
for (let i = targetIndex; i >= 0; i--) {
|
||||||
|
const startTime = parseStartTime(milestones[i].startedAt);
|
||||||
|
if (startTime) {
|
||||||
|
return { index: i, startTime };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findBaselineMilestone = (
|
||||||
|
milestones: IReleasePlanMilestone[],
|
||||||
|
targetIndex: number,
|
||||||
|
activeMilestoneId?: string,
|
||||||
|
): { index: number; startTime: Date } | null => {
|
||||||
|
if (!activeMilestoneId) {
|
||||||
|
return findMostRecentStartedMilestone(milestones, targetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIndex = milestones.findIndex((m) => m.id === activeMilestoneId);
|
||||||
|
if (activeIndex === -1 || activeIndex > targetIndex) {
|
||||||
|
return findMostRecentStartedMilestone(milestones, targetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeStartTime = parseStartTime(milestones[activeIndex].startedAt);
|
||||||
|
if (activeStartTime) {
|
||||||
|
return { index: activeIndex, startTime: activeStartTime };
|
||||||
|
}
|
||||||
|
|
||||||
|
return findMostRecentStartedMilestone(milestones, targetIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTimeFromBaseline = (
|
||||||
|
milestones: IReleasePlanMilestone[],
|
||||||
|
baseline: { index: number; startTime: Date },
|
||||||
|
targetIndex: number,
|
||||||
|
): Date | null => {
|
||||||
|
let currentTime = baseline.startTime;
|
||||||
|
|
||||||
|
for (let i = baseline.index; i < targetIndex; i++) {
|
||||||
|
const previousMilestone = milestones[i];
|
||||||
|
const intervalMinutes = getIntervalMinutes(previousMilestone);
|
||||||
|
|
||||||
|
if (!intervalMinutes) return null;
|
||||||
|
|
||||||
|
currentTime = addMinutes(currentTime, intervalMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateMilestoneStartTime = (
|
||||||
|
milestones: IReleasePlanMilestone[],
|
||||||
|
targetMilestoneId: string,
|
||||||
|
activeMilestoneId?: string,
|
||||||
|
): Date | null => {
|
||||||
|
const targetIndex = milestones.findIndex((m) => m.id === targetMilestoneId);
|
||||||
|
if (targetIndex === -1) return null;
|
||||||
|
|
||||||
|
const baseline = findBaselineMilestone(
|
||||||
|
milestones,
|
||||||
|
targetIndex,
|
||||||
|
activeMilestoneId,
|
||||||
|
);
|
||||||
|
if (!baseline) return null;
|
||||||
|
|
||||||
|
if (baseline.index === targetIndex) {
|
||||||
|
return baseline.startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return calculateTimeFromBaseline(milestones, baseline, targetIndex);
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user