From 463b79ca8904342ac5ec89f8527b4add28abcd47 Mon Sep 17 00:00:00 2001 From: aschantraine Date: Mon, 21 Sep 2026 15:47:05 +0200 Subject: [PATCH 1/6] front: don't narrow update type in isOriginArrivalUpdate Signed-off-by: aschantraine --- .../modules/timesStops/helpers/timePropagation.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/front/src/modules/timesStops/helpers/timePropagation.ts b/front/src/modules/timesStops/helpers/timePropagation.ts index cdace335d86..055e8343d4f 100644 --- a/front/src/modules/timesStops/helpers/timePropagation.ts +++ b/front/src/modules/timesStops/helpers/timePropagation.ts @@ -7,20 +7,13 @@ import { subtractStartTime, } from 'utils/duration'; -import type { - ArrivalUpdate, - BatchTimesUpdate, - CellUpdate, - PropagationMode, - PropagationResult, -} from '../types'; +import type { BatchTimesUpdate, CellUpdate, PropagationMode, PropagationResult } 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; +const isOriginArrivalUpdate = (update: Exclude): boolean => + update.field === 'requestedArrival' && update.row.opOnPathIndex === 0; const toHmsDuration = (date: StartTime) => date instanceof Date From 92ac132d7f007108e8001156aeb66e37af3132ce Mon Sep 17 00:00:00 2001 From: aschantraine Date: Mon, 21 Sep 2026 15:48:31 +0200 Subject: [PATCH 2/6] front: make stop duration absorb a fromDeparture arrival change Signed-off-by: aschantraine --- .../helpers/stopDurationPropagation.ts | 21 +++++++++++++++++-- .../timesStops/helpers/timePropagation.ts | 18 ++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/front/src/modules/timesStops/helpers/stopDurationPropagation.ts b/front/src/modules/timesStops/helpers/stopDurationPropagation.ts index 37d768fe1e4..f44c57a150c 100644 --- a/front/src/modules/timesStops/helpers/stopDurationPropagation.ts +++ b/front/src/modules/timesStops/helpers/stopDurationPropagation.ts @@ -2,10 +2,27 @@ import type { ScheduleItem, TimetableType } from 'common/api/osrdEditoastApi'; import type { Train } from 'reducers/osrdconf/types'; import { Duration, subtractDurationFromStartTime } from 'utils/duration'; +import { ONE_DAY } from '../consts'; import type { StopDurationUpdate, PropagationResult } from '../types'; import { cascadeArrivals } from './arrivalCascade'; import { insertScheduleItemInOrder } from './cellUpdate'; -import { formatSignedDelta, getTruncatedToSecondStartTime } from './utils'; +import { + formatSignedDelta, + getTruncatedToSecondSchedule, + getTruncatedToSecondStartTime, +} from './utils'; + +/** Bring negative stop durations back within a day. */ +const normalizeStopDurations = (schedule: ScheduleItem[]): ScheduleItem[] => + 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 055e8343d4f..a0319cabb9c 100644 --- a/front/src/modules/timesStops/helpers/timePropagation.ts +++ b/front/src/modules/timesStops/helpers/timePropagation.ts @@ -152,6 +152,24 @@ export const propagateTime = ( ); } + // An arrival update propagated fromDeparture is the opposite delta applied to the stop duration if it exists. + if ( + update.field === 'requestedArrival' && + update.propagationMode === 'fromDeparture' && + update.row.stopDuration + ) { + return propagateStopDuration( + { + row: update.row, + field: 'stopDuration', + value: update.row.stopDuration.sub(delta).total('second'), + propagationMode: 'fromDeparture', + }, + selectedTrain, + timetableType + ); + } + if (update.propagationMode === 'atThisWaypoint' || !update.row.pathStepId) return undefined; return propagateFromEditedPoint( delta, From ade5449bef912396c0721c44be9d77cd9ba3441f Mon Sep 17 00:00:00 2001 From: aschantraine Date: Wed, 23 Sep 2026 17:47:11 +0200 Subject: [PATCH 3/6] front: refactor propagateTime to see clearly each mode behaviour Signed-off-by: aschantraine --- .../timesStops/helpers/timePropagation.ts | 124 +++++++++--------- 1 file changed, 61 insertions(+), 63 deletions(-) diff --git a/front/src/modules/timesStops/helpers/timePropagation.ts b/front/src/modules/timesStops/helpers/timePropagation.ts index a0319cabb9c..157981ddc78 100644 --- a/front/src/modules/timesStops/helpers/timePropagation.ts +++ b/front/src/modules/timesStops/helpers/timePropagation.ts @@ -12,9 +12,6 @@ import { cascadeArrivals } from './arrivalCascade'; import { propagateStopDuration } from './stopDurationPropagation'; import { formatSignedDelta, getTruncatedToSecondStartTime } from './utils'; -const isOriginArrivalUpdate = (update: Exclude): boolean => - update.field === 'requestedArrival' && update.row.opOnPathIndex === 0; - const toHmsDuration = (date: StartTime) => date instanceof Date ? new Duration({ @@ -110,72 +107,73 @@ 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 (update.propagationMode === 'shiftAllWaypoints') - return propagateShiftAll(delta, 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( - delta, - update.row.pathStepId!, + if (delta === null || !pathStepId) return undefined; + + 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, - 'fromDeparture', timetableType ); - } - // An arrival update propagated fromDeparture is the opposite delta applied to the stop duration if it exists. - if ( - update.field === 'requestedArrival' && - update.propagationMode === 'fromDeparture' && - update.row.stopDuration - ) { - return propagateStopDuration( - { - row: update.row, - field: 'stopDuration', - value: update.row.stopDuration.sub(delta).total('second'), - propagationMode: 'fromDeparture', - }, - selectedTrain, - timetableType - ); + // 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 'atThisWaypoint': + // At origin, the arrival only moves start_time. Following offsets are compensated so their + // absolute times stay the same — which is exactly what fromDeparture does. + // Anywhere else, only the edited cell changes: left to the generic single-row edit. + return isOrigin && isArrivalUpdate + ? propagateFromEditedPoint(delta, pathStepId, selectedTrain, 'fromDeparture', timetableType) + : undefined; } - - if (update.propagationMode === 'atThisWaypoint' || !update.row.pathStepId) return undefined; - return propagateFromEditedPoint( - delta, - update.row.pathStepId, - selectedTrain, - update.propagationMode, - timetableType - ); }; From 199668389f1f211a374269120f0a4ea7e61516f2 Mon Sep 17 00:00:00 2001 From: aschantraine Date: Wed, 23 Sep 2026 18:07:46 +0200 Subject: [PATCH 4/6] front: change atThisWaypoint propagation behaviour for departure Applying a change with atThisWaypoint mode on a requested departure now moves the waypoint arrival and keep the stop duration. Signed-off-by: aschantraine --- .../timesStops/helpers/timePropagation.ts | 55 +++++++++++++++++-- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/front/src/modules/timesStops/helpers/timePropagation.ts b/front/src/modules/timesStops/helpers/timePropagation.ts index 157981ddc78..e5cdfd24257 100644 --- a/front/src/modules/timesStops/helpers/timePropagation.ts +++ b/front/src/modules/timesStops/helpers/timePropagation.ts @@ -10,7 +10,11 @@ import { import type { BatchTimesUpdate, CellUpdate, PropagationMode, PropagationResult } from '../types'; import { cascadeArrivals } from './arrivalCascade'; import { propagateStopDuration } from './stopDurationPropagation'; -import { formatSignedDelta, getTruncatedToSecondStartTime } from './utils'; +import { + formatSignedDelta, + getTruncatedToSecondSchedule, + getTruncatedToSecondStartTime, +} from './utils'; const toHmsDuration = (date: StartTime) => date instanceof Date @@ -86,6 +90,35 @@ const propagateFromEditedPoint = ( }; }; +/** + * Move the waypoint as a block: arrival and departure move, stop duration is kept. + */ +const applyAtThisWaypoint = ( + delta: Duration, + editedPathStepId: string, + 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) => + item.at === editedPathStepId && item.arrival + ? { ...item, arrival: getTruncatedToSecondSchedule(item.arrival).add(delta).toISOString() } + : item + ); + + return { + updatedPath: selectedTrain.path, + updatedSchedule: cascadeArrivals({ + schedule: editedSchedule, + path: selectedTrain.path, + fromPathIndex: editedPathIndex, + }), + updatedStartTime: getTruncatedToSecondStartTime(selectedTrain, timetableType), + }; +}; + const propagateShiftAll = ( delta: Duration, selectedTrain: Train, @@ -169,11 +202,21 @@ export const propagateTime = ( ); case 'atThisWaypoint': - // At origin, the arrival only moves start_time. Following offsets are compensated so their + // 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. - // Anywhere else, only the edited cell changes: left to the generic single-row edit. - return isOrigin && isArrivalUpdate - ? propagateFromEditedPoint(delta, pathStepId, selectedTrain, 'fromDeparture', timetableType) - : undefined; + if (isOrigin) + return propagateFromEditedPoint( + delta, + pathStepId, + selectedTrain, + 'fromDeparture', + 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 + : applyAtThisWaypoint(delta, pathStepId, selectedTrain, timetableType); } }; From 2c7b62c97b4df939bea41772c96b486a2fad036d Mon Sep 17 00:00:00 2001 From: aschantraine Date: Mon, 21 Sep 2026 16:12:48 +0200 Subject: [PATCH 5/6] front: use a mode list to build propagation menu items Signed-off-by: aschantraine --- .../timesStops/TimePropagationMenu.tsx | 66 +++++-------------- 1 file changed, 18 insertions(+), 48 deletions(-) diff --git a/front/src/modules/timesStops/TimePropagationMenu.tsx b/front/src/modules/timesStops/TimePropagationMenu.tsx index f6eb586e380..7522ace071c 100644 --- a/front/src/modules/timesStops/TimePropagationMenu.tsx +++ b/front/src/modules/timesStops/TimePropagationMenu.tsx @@ -18,6 +18,13 @@ type TimePropagationMenuProps = { isOriginArrival?: boolean; }; +const MODE_ITEMS: { mode: PropagationMode; icon: React.ReactNode; className?: string }[] = [ + { mode: 'shiftAllWaypoints', icon: }, + { mode: 'fromDeparture', icon: }, + { mode: 'atThisWaypoint', icon: , className: 'selected' }, + { mode: 'toDestination', icon: }, +]; + const TimePropagationMenu = ({ isOpen, anchorRef, @@ -29,55 +36,18 @@ const TimePropagationMenu = ({ 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, + 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 ( Date: Wed, 23 Sep 2026 18:55:00 +0200 Subject: [PATCH 6/6] front: add a new propagation mode atThisTime In this mode, the edited time is the only one to move. The stop duration absorbs the change. Signed-off-by: aschantraine --- front/public/locales/en/translation.json | 1 + front/public/locales/fr/translation.json | 1 + front/src/modules/timesStops/TimeCell.tsx | 1 + .../timesStops/TimePropagationMenu.tsx | 4 + .../helpers/stopDurationPropagation.ts | 2 +- .../timesStops/helpers/timePropagation.ts | 78 ++++++++++++++++--- front/src/modules/timesStops/types.ts | 3 +- 7 files changed, 76 insertions(+), 14 deletions(-) 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 7522ace071c..6ee347f06fc 100644 --- a/front/src/modules/timesStops/TimePropagationMenu.tsx +++ b/front/src/modules/timesStops/TimePropagationMenu.tsx @@ -14,6 +14,7 @@ type TimePropagationMenuProps = { newValue: Date | null; onSelectMode: (mode: PropagationMode) => void; disableFromDeparture?: boolean; + disableAtThisTime?: boolean; disableToDestination?: boolean; isOriginArrival?: boolean; }; @@ -22,6 +23,7 @@ const MODE_ITEMS: { mode: PropagationMode; icon: React.ReactNode; className?: st { mode: 'shiftAllWaypoints', icon: }, { mode: 'fromDeparture', icon: }, { mode: 'atThisWaypoint', icon: , className: 'selected' }, + { mode: 'atThisTime', icon: }, { mode: 'toDestination', icon: }, ]; @@ -32,12 +34,14 @@ const TimePropagationMenu = ({ newValue, onSelectMode, disableFromDeparture = false, + disableAtThisTime = false, disableToDestination = false, isOriginArrival = false, }: TimePropagationMenuProps) => { const { t } = useTranslation('translation', { keyPrefix: 'timeStopTable.propagationMenu' }); const disabledByMode: Partial> = { fromDeparture: disableFromDeparture, + atThisTime: disableAtThisTime, toDestination: disableToDestination, }; diff --git a/front/src/modules/timesStops/helpers/stopDurationPropagation.ts b/front/src/modules/timesStops/helpers/stopDurationPropagation.ts index f44c57a150c..273332a159d 100644 --- a/front/src/modules/timesStops/helpers/stopDurationPropagation.ts +++ b/front/src/modules/timesStops/helpers/stopDurationPropagation.ts @@ -13,7 +13,7 @@ import { } from './utils'; /** Bring negative stop durations back within a day. */ -const normalizeStopDurations = (schedule: ScheduleItem[]): ScheduleItem[] => +export const normalizeStopDurations = (schedule: ScheduleItem[]): ScheduleItem[] => schedule.map((item) => { const stop = item.stop_for ? getTruncatedToSecondSchedule(item.stop_for) : null; if (!stop || stop.ms >= 0) return item; diff --git a/front/src/modules/timesStops/helpers/timePropagation.ts b/front/src/modules/timesStops/helpers/timePropagation.ts index e5cdfd24257..a19b3adc885 100644 --- a/front/src/modules/timesStops/helpers/timePropagation.ts +++ b/front/src/modules/timesStops/helpers/timePropagation.ts @@ -7,9 +7,15 @@ import { subtractStartTime, } from 'utils/duration'; -import type { BatchTimesUpdate, CellUpdate, PropagationMode, PropagationResult } from '../types'; +import type { + BatchTimesUpdate, + CellUpdate, + PropagationMode, + PropagationResult, + RequestedTimeField, +} from '../types'; import { cascadeArrivals } from './arrivalCascade'; -import { propagateStopDuration } from './stopDurationPropagation'; +import { normalizeStopDurations, propagateStopDuration } from './stopDurationPropagation'; import { formatSignedDelta, getTruncatedToSecondSchedule, @@ -33,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); @@ -91,27 +97,42 @@ const propagateFromEditedPoint = ( }; /** - * Move the waypoint as a block: arrival and departure move, stop duration is kept. + * 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 applyAtThisWaypoint = ( +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) => - item.at === editedPathStepId && item.arrival - ? { ...item, arrival: getTruncatedToSecondSchedule(item.arrival).add(delta).toISOString() } - : item - ); + 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: editedSchedule, + schedule: normalizeStopDurations(editedSchedule), path: selectedTrain.path, fromPathIndex: editedPathIndex, }), @@ -201,6 +222,32 @@ export const propagateTime = ( 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. @@ -217,6 +264,13 @@ export const propagateTime = ( // generic single-row edit, while a departure update takes the arrival along with it. return isArrivalUpdate ? undefined - : applyAtThisWaypoint(delta, pathStepId, selectedTrain, timetableType); + : 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';