diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx index b862900db9..4b19be755a 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/MilestoneProgressionForm/MilestoneProgressionForm.tsx @@ -4,6 +4,7 @@ import { useMilestoneProgressionForm } from '../hooks/useMilestoneProgressionFor import { MilestoneProgressionTimeInput } from './MilestoneProgressionTimeInput.tsx'; import type { ChangeMilestoneProgressionSchema } from 'openapi'; import type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx'; +import { useMilestoneProgressionInfo } from '../hooks/useMilestoneProgressionInfo.ts'; const StyledFormContainer = styled('div')(({ theme }) => ({ display: 'flex', @@ -54,6 +55,13 @@ const StyledErrorMessage = styled('span')(({ theme }) => ({ paddingLeft: theme.spacing(3.25), })); +const StyledInfoLine = styled('span')(({ theme }) => ({ + color: theme.palette.text.secondary, + fontSize: theme.typography.caption.fontSize, + paddingLeft: theme.spacing(3.25), + fontStyle: 'italic', +})); + interface IMilestoneProgressionFormProps { sourceMilestoneId: string; targetMilestoneId: string; @@ -81,6 +89,12 @@ export const MilestoneProgressionForm = ({ status, ); + const progressionInfo = useMilestoneProgressionInfo( + form.getIntervalMinutes(), + sourceMilestoneStartedAt, + status, + ); + const handleSubmit = async () => { if (!form.validate()) { return; @@ -103,14 +117,18 @@ export const MilestoneProgressionForm = ({ - Proceed to the next milestone after + Proceed after + from milestone start + {progressionInfo && ( + {progressionInfo} + )} {form.errors.time && ( {form.errors.time} )} diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx index 3b8dedbb67..780c7013cd 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/MilestoneTransitionDisplay.tsx @@ -10,6 +10,7 @@ import { import type { ChangeMilestoneProgressionSchema } from 'openapi'; import type { ReactNode } from 'react'; import { useEffect } from 'react'; +import { useMilestoneProgressionInfo } from '../hooks/useMilestoneProgressionInfo.ts'; const StyledFormWrapper = styled('div', { shouldForwardProp: (prop) => prop !== 'hasChanged', @@ -97,6 +98,13 @@ const StyledErrorMessage = styled('span')(({ theme }) => ({ paddingLeft: theme.spacing(3.25), })); +const StyledInfoLine = styled('span')(({ theme }) => ({ + color: theme.palette.text.secondary, + fontSize: theme.typography.caption.fontSize, + paddingLeft: theme.spacing(3.25), + fontStyle: 'italic', +})); + interface IMilestoneTransitionDisplayProps { intervalMinutes: number; targetMilestoneId: string; @@ -129,6 +137,7 @@ export const ReadonlyMilestoneTransitionDisplay = ({ {initial.value} {initial.unit} + from milestone start ); @@ -159,6 +168,12 @@ export const MilestoneTransitionDisplay = ({ const currentIntervalMinutes = form.getIntervalMinutes(); const hasChanged = currentIntervalMinutes !== intervalMinutes; + const progressionInfo = useMilestoneProgressionInfo( + currentIntervalMinutes, + sourceMilestoneStartedAt ?? null, + status, + ); + useEffect(() => { const newInitial = getTimeValueAndUnitFromMinutes(intervalMinutes); form.setTimeValue(newInitial.value); @@ -214,15 +229,16 @@ export const MilestoneTransitionDisplay = ({ - - Proceed to the next milestone after - + Proceed after + + from milestone start + {!hasChanged && ( @@ -238,6 +254,9 @@ export const MilestoneTransitionDisplay = ({ )} + {progressionInfo && ( + {progressionInfo} + )} {form.errors.time && ( {form.errors.time} )} diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/getMilestoneProgressionInfo.test.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/getMilestoneProgressionInfo.test.ts new file mode 100644 index 0000000000..a1c4f7f4e3 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/getMilestoneProgressionInfo.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { getMilestoneProgressionInfo } from './getMilestoneProgressionInfo.js'; + +describe('getMilestoneProgressionInfo', () => { + const currentTime = new Date('2025-10-31T15:00:00.000Z'); + + it('returns immediate proceed message when elapsed >= interval', () => { + const startedAt = '2025-10-31T14:00:00.000Z'; + const res = getMilestoneProgressionInfo( + 30, + startedAt, + 'en-US', + currentTime, + ); + expect(res).toBeTruthy(); + expect(res as string).toMatch(/^Already .* in this milestone\.$/); + }); + + it('returns proceed time and remaining message when elapsed < interval', () => { + const startedAt = '2025-10-31T14:00:00.000Z'; + const res = getMilestoneProgressionInfo( + 120, + startedAt, + 'en-US', + currentTime, + ); + expect(res).toBeTruthy(); + expect(res as string).toMatch(/^Will proceed at .* \(in .*\)\.$/); + }); +}); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/getMilestoneProgressionInfo.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/getMilestoneProgressionInfo.ts new file mode 100644 index 0000000000..54cbcb21b8 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/getMilestoneProgressionInfo.ts @@ -0,0 +1,32 @@ +import { addMinutes, differenceInMinutes, formatDistance } from 'date-fns'; +import { formatDateYMDHM } from 'utils/formatDate.ts'; + +export const getMilestoneProgressionInfo = ( + intervalMinutes: number, + sourceMilestoneStartedAt: string | null | undefined, + locale: string, + currentTime: Date = new Date(), +): string | null => { + if (!sourceMilestoneStartedAt) { + return null; + } + + const startDate = new Date(sourceMilestoneStartedAt); + const elapsedMinutes = differenceInMinutes(currentTime, startDate); + const proceedDate = addMinutes(startDate, intervalMinutes); + + if (elapsedMinutes >= intervalMinutes) { + const elapsedTime = formatDistance(startDate, currentTime, { + addSuffix: false, + }); + return `Already ${elapsedTime} in this milestone.`; + } + + const proceedTime = formatDateYMDHM(proceedDate, locale); + const remainingTime = formatDistance(proceedDate, currentTime, { + addSuffix: false, + }); + return `Will proceed at ${proceedTime} (in ${remainingTime}).`; +}; + +export default getMilestoneProgressionInfo; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/useMilestoneProgressionInfo.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/useMilestoneProgressionInfo.ts new file mode 100644 index 0000000000..2f986f3769 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/useMilestoneProgressionInfo.ts @@ -0,0 +1,20 @@ +import { useLocationSettings } from 'hooks/useLocationSettings'; +import { getMilestoneProgressionInfo } from './getMilestoneProgressionInfo.ts'; +import type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx'; + +export const useMilestoneProgressionInfo = ( + intervalMinutes: number, + sourceMilestoneStartedAt?: string | null, + status?: MilestoneStatus, +) => { + const { locationSettings } = useLocationSettings(); + if (!status || status.type !== 'active') { + return null; + } + + return getMilestoneProgressionInfo( + intervalMinutes, + sourceMilestoneStartedAt, + locationSettings.locale, + ); +};