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>
|
||||
</ChangeItemWrapper>
|
||||
<TabPanel>
|
||||
<ReleasePlanMilestone readonly milestone={newMilestone} />
|
||||
<ReleasePlanMilestone
|
||||
readonly
|
||||
milestone={newMilestone}
|
||||
allMilestones={releasePlan.milestones}
|
||||
activeMilestoneId={releasePlan.activeMilestoneId}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel variant='diff'>
|
||||
<EventDiff
|
||||
|
||||
@ -371,6 +371,8 @@ export const ReleasePlan = ({
|
||||
projectId={projectId}
|
||||
environment={environment}
|
||||
onUpdate={refetch}
|
||||
allMilestones={milestones}
|
||||
activeMilestoneId={activeMilestoneId}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
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,
|
||||
} from './ReleasePlanMilestoneStatus.tsx';
|
||||
import { useState } from 'react';
|
||||
import { MilestoneNextStartTime } from './MilestoneNextStartTime.tsx';
|
||||
|
||||
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
|
||||
import { StrategyItem } from '../../FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx';
|
||||
@ -105,6 +106,8 @@ interface IReleasePlanMilestoneProps {
|
||||
projectId?: string;
|
||||
environment?: string;
|
||||
onUpdate?: () => void;
|
||||
allMilestones: IReleasePlanMilestone[];
|
||||
activeMilestoneId?: string;
|
||||
}
|
||||
|
||||
export const ReleasePlanMilestone = ({
|
||||
@ -119,6 +122,8 @@ export const ReleasePlanMilestone = ({
|
||||
projectId,
|
||||
environment,
|
||||
onUpdate,
|
||||
allMilestones,
|
||||
activeMilestoneId,
|
||||
}: IReleasePlanMilestoneProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
@ -134,6 +139,15 @@ export const ReleasePlanMilestone = ({
|
||||
{(!readonly && onStartMilestone) ||
|
||||
(status === 'active' && milestone.startedAt) ? (
|
||||
<StyledStatusRow>
|
||||
{!readonly && (
|
||||
<MilestoneNextStartTime
|
||||
milestone={milestone}
|
||||
allMilestones={allMilestones}
|
||||
activeMilestoneId={
|
||||
activeMilestoneId
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!readonly && onStartMilestone && (
|
||||
<ReleasePlanMilestoneStatus
|
||||
status={status}
|
||||
@ -193,6 +207,13 @@ export const ReleasePlanMilestone = ({
|
||||
{(!readonly && onStartMilestone) ||
|
||||
(status === 'active' && milestone.startedAt) ? (
|
||||
<StyledStatusRow>
|
||||
{!readonly && (
|
||||
<MilestoneNextStartTime
|
||||
milestone={milestone}
|
||||
allMilestones={allMilestones}
|
||||
activeMilestoneId={activeMilestoneId}
|
||||
/>
|
||||
)}
|
||||
{!readonly && onStartMilestone && (
|
||||
<ReleasePlanMilestoneStatus
|
||||
status={status}
|
||||
|
||||
@ -2,6 +2,7 @@ import { styled } from '@mui/material';
|
||||
import PlayCircleIcon from '@mui/icons-material/PlayCircle';
|
||||
import PauseCircleIcon from '@mui/icons-material/PauseCircle';
|
||||
import TripOriginIcon from '@mui/icons-material/TripOrigin';
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
|
||||
export type MilestoneStatus = 'not-started' | 'active' | 'paused' | 'completed';
|
||||
|
||||
@ -67,30 +68,49 @@ interface IReleasePlanMilestoneStatusProps {
|
||||
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 = ({
|
||||
status,
|
||||
onStartMilestone,
|
||||
}: IReleasePlanMilestoneStatusProps) => {
|
||||
const statusText =
|
||||
status === 'active'
|
||||
? 'Running'
|
||||
: status === 'paused'
|
||||
? 'Paused (disabled in environment)'
|
||||
: status === 'completed'
|
||||
? 'Restart'
|
||||
: 'Start';
|
||||
|
||||
const statusIcon =
|
||||
status === 'active' ? (
|
||||
<TripOriginIcon />
|
||||
) : status === 'paused' ? (
|
||||
<PauseCircleIcon />
|
||||
) : (
|
||||
<PlayCircleIcon />
|
||||
);
|
||||
const milestoneProgressionsEnabled = useUiFlag('milestoneProgression');
|
||||
|
||||
const statusText = getStatusText(status, milestoneProgressionsEnabled);
|
||||
const statusIcon = getStatusIcon(status);
|
||||
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 (
|
||||
<StyledStatusButton
|
||||
status={status}
|
||||
@ -100,7 +120,7 @@ export const ReleasePlanMilestoneStatus = ({
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{statusIcon}
|
||||
{shouldShowIcon && statusIcon}
|
||||
{statusText}
|
||||
</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