1
0
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:
Fredrik Strand Oseberg 2025-10-13 17:02:30 +02:00 committed by GitHub
parent 331d00b329
commit bc740bbe2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 385 additions and 19 deletions

View File

@ -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

View File

@ -371,6 +371,8 @@ export const ReleasePlan = ({
projectId={projectId}
environment={environment}
onUpdate={refetch}
allMilestones={milestones}
activeMilestoneId={activeMilestoneId}
/>
<ConditionallyRender
condition={isNotLastMilestone}

View File

@ -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>
);
};

View File

@ -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}

View File

@ -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>
);

View File

@ -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
);
});
});

View File

@ -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);
};