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