diff --git a/front/public/locales/en/translation.json b/front/public/locales/en/translation.json
index 8c63528d3ba..060ee703a42 100644
--- a/front/public/locales/en/translation.json
+++ b/front/public/locales/en/translation.json
@@ -871,6 +871,7 @@
"powerRestrictionIncompatibility_one": "{{count}} power restriction code isn't compatible with the electrification.",
"powerRestrictionIncompatibility_other": "{{count}} power restriction codes aren't compatible with the electrification.",
"propagationMenu": {
+ "atThisTime": "at this time",
"atThisWaypoint": "at this waypoint",
"fromDeparture": "from departure",
"shiftAllWaypoints": "shift all waypoints",
diff --git a/front/public/locales/fr/translation.json b/front/public/locales/fr/translation.json
index 9e16acfac60..beae56d639e 100644
--- a/front/public/locales/fr/translation.json
+++ b/front/public/locales/fr/translation.json
@@ -871,6 +871,7 @@
"powerRestrictionIncompatibility_one": "{{count}} code de restriction de puissance n'est pas compatible avec l'électrification.",
"powerRestrictionIncompatibility_other": "{{count}} codes de restriction de puissance ne sont pas compatibles avec l'électrification.",
"propagationMenu": {
+ "atThisTime": "appliquer à cet horaire",
"atThisWaypoint": "appliquer à ce point",
"fromDeparture": "propager vers le début",
"shiftAllWaypoints": "décaler tout le sillon",
diff --git a/front/src/modules/timesStops/TimeCell.tsx b/front/src/modules/timesStops/TimeCell.tsx
index cbcb002aedc..2f2ba336a72 100644
--- a/front/src/modules/timesStops/TimeCell.tsx
+++ b/front/src/modules/timesStops/TimeCell.tsx
@@ -731,6 +731,7 @@ const TimeCell = ({
newValue={editedDate}
onSelectMode={handleSelectPropagationMode}
disableFromDeparture={column.id === 'requestedArrival' && isFirstRow}
+ disableAtThisTime={row.original.stopDuration === null}
disableToDestination={isLastRow}
isOriginArrival={column.id === 'requestedArrival' && isFirstRow}
/>
diff --git a/front/src/modules/timesStops/TimePropagationMenu.tsx b/front/src/modules/timesStops/TimePropagationMenu.tsx
index f6eb586e380..6ee347f06fc 100644
--- a/front/src/modules/timesStops/TimePropagationMenu.tsx
+++ b/front/src/modules/timesStops/TimePropagationMenu.tsx
@@ -14,10 +14,19 @@ type TimePropagationMenuProps = {
newValue: Date | null;
onSelectMode: (mode: PropagationMode) => void;
disableFromDeparture?: boolean;
+ disableAtThisTime?: boolean;
disableToDestination?: boolean;
isOriginArrival?: boolean;
};
+const MODE_ITEMS: { mode: PropagationMode; icon: React.ReactNode; className?: string }[] = [
+ { mode: 'shiftAllWaypoints', icon: },
+ { mode: 'fromDeparture', icon: },
+ { mode: 'atThisWaypoint', icon: , className: 'selected' },
+ { mode: 'atThisTime', icon: },
+ { mode: 'toDestination', icon: },
+];
+
const TimePropagationMenu = ({
isOpen,
anchorRef,
@@ -25,59 +34,24 @@ const TimePropagationMenu = ({
newValue,
onSelectMode,
disableFromDeparture = false,
+ disableAtThisTime = false,
disableToDestination = false,
isOriginArrival = false,
}: TimePropagationMenuProps) => {
const { t } = useTranslation('translation', { keyPrefix: 'timeStopTable.propagationMenu' });
- const shiftAllWaypointsDeltaLabel = formatPropagationDeltaLabelByMode(
- oldValue,
- newValue,
- 'shiftAllWaypoints'
- );
- const fromDepartureDeltaLabel = formatPropagationDeltaLabelByMode(
- oldValue,
- newValue,
- 'fromDeparture'
- );
- const atThisWaypointDeltaLabel = formatPropagationDeltaLabelByMode(
- oldValue,
- newValue,
- 'atThisWaypoint',
- isOriginArrival
- );
- const toDestinationDeltaLabel = formatPropagationDeltaLabelByMode(
- oldValue,
- newValue,
- 'toDestination',
- isOriginArrival
- );
- const selectMode = (mode: PropagationMode) => () => onSelectMode(mode);
+ const disabledByMode: Partial> = {
+ fromDeparture: disableFromDeparture,
+ atThisTime: disableAtThisTime,
+ toDestination: disableToDestination,
+ };
- const items: OSRDMenuItem[] = [
- {
- title: `${shiftAllWaypointsDeltaLabel} ${t('shiftAllWaypoints')}`,
- icon: ,
- onClick: selectMode('shiftAllWaypoints'),
- },
- {
- title: `${fromDepartureDeltaLabel} ${t('fromDeparture')}`,
- icon: ,
- disabled: disableFromDeparture,
- onClick: selectMode('fromDeparture'),
- },
- {
- title: `${atThisWaypointDeltaLabel} ${t('atThisWaypoint')}`,
- icon: ,
- className: 'selected',
- onClick: selectMode('atThisWaypoint'),
- },
- {
- title: `${toDestinationDeltaLabel} ${t('toDestination')}`,
- icon: ,
- disabled: disableToDestination,
- onClick: selectMode('toDestination'),
- },
- ];
+ const items: OSRDMenuItem[] = MODE_ITEMS.map(({ mode, icon, className }) => ({
+ title: `${formatPropagationDeltaLabelByMode(oldValue, newValue, mode, isOriginArrival)} ${t(mode)}`,
+ icon,
+ className,
+ disabled: disabledByMode[mode],
+ onClick: () => onSelectMode(mode),
+ }));
return (
+ schedule.map((item) => {
+ const stop = item.stop_for ? getTruncatedToSecondSchedule(item.stop_for) : null;
+ if (!stop || stop.ms >= 0) return item;
+
+ return {
+ ...item,
+ stop_for: new Duration({ milliseconds: stop.ms + ONE_DAY.ms }).toISOString(),
+ };
+ });
export const formatStopDurationDeltaLabel = (
oldValue: Duration | null,
@@ -62,7 +79,7 @@ export const propagateStopDuration = (
// Shift every scheduled arrival after the edited point by +delta, in path order. Bump +24h
// if a shifted arrival ends up before the previous departure.
const updatedSchedule = cascadeArrivals({
- schedule: updatedScheduleStop,
+ schedule: normalizeStopDurations(updatedScheduleStop),
path: selectedTrain.path,
fromPathIndex: editedPathIndex + 1,
shift: (arrival) => arrival.add(delta),
diff --git a/front/src/modules/timesStops/helpers/timePropagation.ts b/front/src/modules/timesStops/helpers/timePropagation.ts
index cdace335d86..a19b3adc885 100644
--- a/front/src/modules/timesStops/helpers/timePropagation.ts
+++ b/front/src/modules/timesStops/helpers/timePropagation.ts
@@ -8,19 +8,19 @@ import {
} from 'utils/duration';
import type {
- ArrivalUpdate,
BatchTimesUpdate,
CellUpdate,
PropagationMode,
PropagationResult,
+ RequestedTimeField,
} from '../types';
import { cascadeArrivals } from './arrivalCascade';
-import { propagateStopDuration } from './stopDurationPropagation';
-import { formatSignedDelta, getTruncatedToSecondStartTime } from './utils';
-
-const isOriginArrivalUpdate = (
- update: Exclude
-): update is ArrivalUpdate => update.field === 'requestedArrival' && update.row.opOnPathIndex === 0;
+import { normalizeStopDurations, propagateStopDuration } from './stopDurationPropagation';
+import {
+ formatSignedDelta,
+ getTruncatedToSecondSchedule,
+ getTruncatedToSecondStartTime,
+} from './utils';
const toHmsDuration = (date: StartTime) =>
date instanceof Date
@@ -39,7 +39,7 @@ const computeDeltaForPropagationMode = (
): Duration | null => {
if (!oldValue || !newValue) return null;
// At the origin arrival, or for shiftAll and fromDeparture, only HH:mm:ss is compared (start_time absorbs the shift).
- // For atThisWaypoint and toDestination, full date-times are compared, so it can produce a D+1.
+ // For atThisTime, atThisWaypoint and toDestination, full date-times are compared, so it can produce a D+1.
return isOriginArrival || mode === 'shiftAllWaypoints' || mode === 'fromDeparture'
? toHmsDuration(newValue).sub(toHmsDuration(oldValue))
: subtractStartTime(newValue, oldValue);
@@ -96,6 +96,50 @@ const propagateFromEditedPoint = (
};
};
+/**
+ * Apply an update to the edited point alone.
+ * - atThisWaypoint: the waypoint moves as a block, arrival and departure move, stop duration is kept.
+ * - atThisTime: only the edited time moves, the stop duration absorbs the change.
+ */
+const applyAtThisPoint = (
+ delta: Duration,
+ editedPathStepId: string,
+ field: RequestedTimeField,
+ mode: 'atThisWaypoint' | 'atThisTime',
+ selectedTrain: Train,
+ timetableType: TimetableType
+): PropagationResult | undefined => {
+ const editedPathIndex = selectedTrain.path.findIndex((step) => step.id === editedPathStepId);
+ if (editedPathIndex < 0) return undefined;
+
+ const editedSchedule = (selectedTrain.schedule ?? []).map((item) => {
+ if (item.at !== editedPathStepId) return item;
+ const stop = item.stop_for ? getTruncatedToSecondSchedule(item.stop_for) : null;
+
+ // An edited departure atThisTime is the stop moving by +delta, the arrival doesn't move.
+ if (field === 'requestedDeparture' && mode === 'atThisTime')
+ return stop ? { ...item, stop_for: stop.add(delta).toISOString() } : item;
+
+ // Otherwise the arrival moves by +delta, either alone — the waypoint moves as a block and its
+ // departure follows — or against a stop moving by -delta, which keeps that departure in place.
+ if (!item.arrival) return item;
+ const arrival = getTruncatedToSecondSchedule(item.arrival).add(delta).toISOString();
+ return mode === 'atThisTime' && stop
+ ? { ...item, arrival, stop_for: stop.sub(delta).toISOString() }
+ : { ...item, arrival };
+ });
+
+ return {
+ updatedPath: selectedTrain.path,
+ updatedSchedule: cascadeArrivals({
+ schedule: normalizeStopDurations(editedSchedule),
+ path: selectedTrain.path,
+ fromPathIndex: editedPathIndex,
+ }),
+ updatedStartTime: getTruncatedToSecondStartTime(selectedTrain, timetableType),
+ };
+};
+
const propagateShiftAll = (
delta: Duration,
selectedTrain: Train,
@@ -117,54 +161,116 @@ export const propagateTime = (
if (update.field !== 'requestedArrival' && update.field !== 'requestedDeparture')
return undefined;
- const oldValue = update.row[update.field];
- const newValue = update.value;
- const isOriginArrival = isOriginArrivalUpdate(update);
+ const { pathStepId, stopDuration, opOnPathIndex } = update.row;
+ const isArrivalUpdate = update.field === 'requestedArrival';
+ const isOrigin = opOnPathIndex === 0;
const delta = computeDeltaForPropagationMode(
- oldValue,
- newValue,
+ update.row[update.field],
+ update.value,
update.propagationMode,
- isOriginArrival
+ isArrivalUpdate && isOrigin
);
- if (delta === null) return undefined;
-
- // A departure update propagated toDestination is the same delta applied to the stop duration.
- if (update.field === 'requestedDeparture' && update.propagationMode === 'toDestination') {
- return propagateStopDuration(
- {
- row: update.row,
- field: 'stopDuration',
- value: (update.row.stopDuration ?? Duration.zero).add(delta).total('second'),
- propagationMode: 'toDestination',
- },
- selectedTrain,
- timetableType
- );
- }
+ if (delta === null || !pathStepId) return undefined;
- if (update.propagationMode === 'shiftAllWaypoints')
- return propagateShiftAll(delta, selectedTrain, timetableType);
+ switch (update.propagationMode) {
+ case 'shiftAllWaypoints':
+ return propagateShiftAll(delta, selectedTrain, timetableType);
+
+ case 'toDestination':
+ // A departure update propagated toDestination is the same delta applied to the stop duration.
+ if (!isArrivalUpdate)
+ return propagateStopDuration(
+ {
+ row: update.row,
+ field: 'stopDuration',
+ value: (stopDuration ?? Duration.zero).add(delta).total('second'),
+ propagationMode: 'toDestination',
+ },
+ selectedTrain,
+ timetableType
+ );
- if (isOriginArrival) {
- // At origin, every mode but toDestination only moves start_time. Following offsets are compensated so
- // their absolute times stay the same — which is exactly what fromDeparture does.
- return update.propagationMode === 'toDestination'
- ? propagateShiftAll(delta, selectedTrain, timetableType)
- : propagateFromEditedPoint(
+ // At the origin the arrival is start_time, so moving it along with everything after it moves the whole train.
+ return isOrigin
+ ? propagateShiftAll(delta, selectedTrain, timetableType)
+ : propagateFromEditedPoint(
+ delta,
+ pathStepId,
+ selectedTrain,
+ 'toDestination',
+ timetableType
+ );
+
+ case 'fromDeparture':
+ // An arrival update propagated fromDeparture is the opposite delta applied to the stop duration if it exists.
+ return isArrivalUpdate && !isOrigin && stopDuration
+ ? propagateStopDuration(
+ {
+ row: update.row,
+ field: 'stopDuration',
+ value: stopDuration.sub(delta).total('second'),
+ propagationMode: 'fromDeparture',
+ },
+ selectedTrain,
+ timetableType
+ )
+ : propagateFromEditedPoint(
+ delta,
+ pathStepId,
+ selectedTrain,
+ 'fromDeparture',
+ timetableType
+ );
+
+ case 'atThisTime':
+ // The stop duration absorbs the change, so a point without one is left to the
+ // generic single-row edit.
+ if (!stopDuration) return undefined;
+
+ // At origin arrival, it means moving start_time and compensating the offsets after it, which a stop duration update does.
+ return isOrigin && isArrivalUpdate
+ ? propagateStopDuration(
+ {
+ row: update.row,
+ field: 'stopDuration',
+ value: stopDuration.sub(delta).total('second'),
+ propagationMode: 'fromDeparture',
+ },
+ selectedTrain,
+ timetableType
+ )
+ : applyAtThisPoint(
+ delta,
+ pathStepId,
+ update.field,
+ 'atThisTime',
+ selectedTrain,
+ timetableType
+ );
+
+ case 'atThisWaypoint':
+ // At origin, the point only moves start_time. Following offsets are compensated so their
+ // absolute times stay the same — which is exactly what fromDeparture does.
+ if (isOrigin)
+ return propagateFromEditedPoint(
delta,
- update.row.pathStepId!,
+ pathStepId,
selectedTrain,
'fromDeparture',
timetableType
);
- }
- if (update.propagationMode === 'atThisWaypoint' || !update.row.pathStepId) return undefined;
- return propagateFromEditedPoint(
- delta,
- update.row.pathStepId,
- selectedTrain,
- update.propagationMode,
- timetableType
- );
+ // Anywhere else, an arrival update changes nothing but its own cell, so it is left to the
+ // generic single-row edit, while a departure update takes the arrival along with it.
+ return isArrivalUpdate
+ ? undefined
+ : applyAtThisPoint(
+ delta,
+ pathStepId,
+ update.field,
+ 'atThisWaypoint',
+ selectedTrain,
+ timetableType
+ );
+ }
};
diff --git a/front/src/modules/timesStops/types.ts b/front/src/modules/timesStops/types.ts
index c0f9bd6df8a..d66007a8eb3 100644
--- a/front/src/modules/timesStops/types.ts
+++ b/front/src/modules/timesStops/types.ts
@@ -79,6 +79,7 @@ export type PropagationMode =
| 'shiftAllWaypoints'
| 'fromDeparture'
| 'atThisWaypoint'
+ | 'atThisTime'
| 'toDestination';
export type PropagationResult = {
@@ -87,7 +88,7 @@ export type PropagationResult = {
updatedStartTime: StartTime;
};
-export type StopPropagationMode = Exclude;
+export type StopPropagationMode = Exclude;
export type UpdateCellStatus = 'updated' | 'skipped';