diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx index 21f1925364..4f7e4e5746 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ReleasePlanChange.tsx @@ -96,7 +96,12 @@ const StartMilestone: FC<{ - + { + 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 ( + + + {text} + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone.tsx index 9e3f1e8557..ed3cc08217 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone.tsx @@ -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) ? ( + {!readonly && ( + + )} {!readonly && onStartMilestone && ( + {!readonly && ( + + )} {!readonly && 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 ; + case 'paused': + return ; + default: + return ; + } +}; + 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' ? ( - - ) : status === 'paused' ? ( - - ) : ( - - ); + 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 ( - {statusIcon} + {shouldShowIcon && statusIcon} {statusText} ); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/utils/calculateMilestoneStartTime.test.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/utils/calculateMilestoneStartTime.test.ts new file mode 100644 index 0000000000..71436b42ca --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/utils/calculateMilestoneStartTime.test.ts @@ -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 + ); + }); +}); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/utils/calculateMilestoneStartTime.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/utils/calculateMilestoneStartTime.ts new file mode 100644 index 0000000000..888e3330d5 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/utils/calculateMilestoneStartTime.ts @@ -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); +};