From 102cda0a0c2ba578899a03294c8ad4fad49900f3 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Mon, 10 Aug 2026 14:48:09 +0200 Subject: [PATCH 01/16] front: use named type for DurationCell props DurationCell's declaration is difficult to read because its props type is non-trivial. Extract that to a separate type. Signed-off-by: Simon Ser --- front/src/modules/timesStops/DurationCell.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/front/src/modules/timesStops/DurationCell.tsx b/front/src/modules/timesStops/DurationCell.tsx index c3397ba1c61..c4c5862d979 100644 --- a/front/src/modules/timesStops/DurationCell.tsx +++ b/front/src/modules/timesStops/DurationCell.tsx @@ -395,18 +395,15 @@ export type DurationCellHandle = { focus: () => void; }; -const DurationCell = ({ - disabled, - clearButtonTitle, - ref, - ...props -}: CellContext & +type DurationCellProps = CellContext & Omit, 'onChange'> & { onCommit?: (seconds: number | null, propagationMode: StopPropagationMode) => void; disabled?: boolean; clearButtonTitle?: string; ref?: React.Ref; - }) => { + }; + +const DurationCell = ({ disabled, clearButtonTitle, ref, ...props }: DurationCellProps) => { const { onCommit, getValue, row, table } = props || {}; const controlledValue = getValue(); const [state, dispatch] = useReducer(durationReducer, controlledValue, initialDurationState); From 63ae87fa33aebb77f8fadc1759e5eec4233c0d9d Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Mon, 10 Aug 2026 16:01:14 +0200 Subject: [PATCH 02/16] front: add StartTime subtraction helpers Signed-off-by: Simon Ser --- front/src/utils/duration.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/front/src/utils/duration.ts b/front/src/utils/duration.ts index 5a90d33ffe8..97b591fa2e4 100644 --- a/front/src/utils/duration.ts +++ b/front/src/utils/duration.ts @@ -142,6 +142,19 @@ export const addDurationToStartTime = (startTime: StartTime, dur: Duration): Sta export const subtractDurationFromDate = (date: Date, dur: Duration) => new Date(date.getTime() - dur.ms); +export const subtractDurationFromStartTime = (startTime: StartTime, dur: Duration) => + startTime instanceof Duration ? startTime.sub(dur) : subtractDurationFromDate(startTime, dur); + +export const subtractStartTime = (a: StartTime, b: StartTime) => { + if (a instanceof Date && b instanceof Date) { + return Duration.subtractDate(a, b); + } else if (a instanceof Duration && b instanceof Duration) { + return a.sub(b); + } else { + throw new Error('Cannot subtract start times with different underlying type'); + } +}; + /** Compute the difference in minutes between two dates, truncated to the minute */ export const minutesBetween = (a: Date, b: Date) => new Duration({ From f8d0b5822367a1f3d44f86744a294137cf6533ff Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 11 Aug 2026 14:49:02 +0200 Subject: [PATCH 03/16] front: drop diffSeconds() This function performs manual time manipulation. Move truncateToSecond() to shared utils and use that instead. Will help with hourly timetables as well. Signed-off-by: Simon Ser --- front/src/modules/timesStops/helpers/cellUpdate.ts | 11 +++++------ .../src/modules/timesStops/helpers/timePropagation.ts | 9 ++------- front/src/modules/timesStops/helpers/utils.ts | 6 ++++++ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/front/src/modules/timesStops/helpers/cellUpdate.ts b/front/src/modules/timesStops/helpers/cellUpdate.ts index 72dafc06310..cd25633fd3b 100644 --- a/front/src/modules/timesStops/helpers/cellUpdate.ts +++ b/front/src/modules/timesStops/helpers/cellUpdate.ts @@ -11,7 +11,7 @@ import { addElementAtIndex } from 'utils/array'; import { Duration } from 'utils/duration'; import type { OptimisticEdit, PendingEdit, TimesStopsRowNew } from '../types'; -import { receptionSignalToSignalBooleans } from './utils'; +import { receptionSignalToSignalBooleans, truncateDateToSecond } from './utils'; /** Compute the insertion index for a new PathStep using row opOnPathIndex values. */ const computeInsertIndex = ( @@ -54,10 +54,6 @@ export const upsertPathStep = ( const computeDeparture = (arrival: Date | null, stop: Duration | null): Date | null => arrival !== null && stop !== null ? new Date(arrival.getTime() + stop.ms) : null; -/** Difference between two dates, truncated to whole seconds. */ -const diffSeconds = (a: Date, b: Date) => - Math.floor(a.getTime() / 1000) - Math.floor(b.getTime() / 1000); - /** State of a stop's schedule in display format (Date / Duration). */ export type ScheduleState = { arrival: Date | null; @@ -147,7 +143,10 @@ export const scheduleStateToApiFields = ( ): { arrival: string | null; stop_for: string | null } => ({ arrival: state.arrival !== null - ? new Duration({ seconds: diffSeconds(state.arrival, startTime) }).toISOString() + ? Duration.subtractDate( + truncateDateToSecond(state.arrival), + truncateDateToSecond(startTime) + ).toISOString() : null, stop_for: state.stop !== null ? state.stop.toISOString() : null, }); diff --git a/front/src/modules/timesStops/helpers/timePropagation.ts b/front/src/modules/timesStops/helpers/timePropagation.ts index 6b63a7a93b8..6b16bcf7c6f 100644 --- a/front/src/modules/timesStops/helpers/timePropagation.ts +++ b/front/src/modules/timesStops/helpers/timePropagation.ts @@ -3,6 +3,7 @@ import type { Train } from 'reducers/osrdconf/types'; import { addDurationToDate, Duration } from 'utils/duration'; import type { ArrivalUpdate, CellUpdate, PropagationMode } from '../types'; +import { truncateDateToSecond } from './utils'; export const ONE_DAY = new Duration({ hours: 24 }); @@ -28,12 +29,6 @@ const computeDelta = (oldValue: Date | null, newValue: Date | null): Duration | return toHmsDuration(newValue).sub(toHmsDuration(oldValue)); }; -const truncateToSecond = (date: Date): Date => { - const truncated = new Date(date); - truncated.setMilliseconds(0); - return truncated; -}; - const computeDeltaForPropagationMode = ( oldValue: Date | null, newValue: Date | null, @@ -42,7 +37,7 @@ const computeDeltaForPropagationMode = ( mode === 'shiftAllWaypoints' || mode === 'fromDeparture' ? computeDelta(oldValue, newValue) : oldValue && newValue - ? Duration.subtractDate(truncateToSecond(newValue), truncateToSecond(oldValue)) + ? Duration.subtractDate(truncateDateToSecond(newValue), truncateDateToSecond(oldValue)) : null; export const formatSignedDelta = (delta: Duration) => { diff --git a/front/src/modules/timesStops/helpers/utils.ts b/front/src/modules/timesStops/helpers/utils.ts index 3de1a0a0118..9a43c682097 100644 --- a/front/src/modules/timesStops/helpers/utils.ts +++ b/front/src/modules/timesStops/helpers/utils.ts @@ -26,6 +26,12 @@ import { import { marginRegExValidation, MarginUnit } from '../consts'; import { TableType, type TimeExtraDays, type TimesStopsInputRow } from '../types'; +export const truncateDateToSecond = (date: Date): Date => { + const truncated = new Date(date); + truncated.setMilliseconds(0); + return truncated; +}; + export const formatSuggestedViasToRowVias = ( operationalPoints: SuggestedOP[], pathSteps: PathStep[], From d026305780439d67a3c320a0fb96f9c969de5523 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 11 Aug 2026 15:17:52 +0200 Subject: [PATCH 04/16] front: introduce Duration.toLocaleString() This mirrors a subset of the Temporal API, to make migration easier to the standard once we can upgrade: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration/toLocaleString `style` and `hours` are forced to a single value because we only need these variations from the standard API. Signed-off-by: Simon Ser --- front/src/utils/duration.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/front/src/utils/duration.ts b/front/src/utils/duration.ts index 97b591fa2e4..bf3da11572b 100644 --- a/front/src/utils/duration.ts +++ b/front/src/utils/duration.ts @@ -96,6 +96,27 @@ export class Duration { total(unit: 'second' | 'minute' | 'hour'): number { return this.ms / UNIT_IN_MS[unit]; } + + /** + * Format a string representing this duration for end-user display. + */ + toLocaleString( + _locale: Intl.Locale | undefined, + { + secondsDisplay = 'always', + }: { style: 'digital'; hours: '2-digit'; secondsDisplay?: 'always' | 'auto' } + ) { + const hours = Math.floor(this.total('hour')); + const minutes = Math.floor(this.total('minute')) % 60; + const seconds = Math.floor(this.total('second')) % 60; + + const parts = [hours, minutes]; + if (secondsDisplay === 'always' || seconds !== 0) { + parts.push(seconds); + } + + return parts.map((value) => value.toString().padStart(2, '0')).join(':'); + } } /** From 01f2f46d4ca664d3f7ea8441dcb9f71899353e94 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 11 Aug 2026 15:19:13 +0200 Subject: [PATCH 05/16] front: use Duration.toLocaleString() in timeToLocaleStringRounded() Signed-off-by: Simon Ser --- front/src/utils/date.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/front/src/utils/date.ts b/front/src/utils/date.ts index e296b5f2259..24cdc6660a6 100644 --- a/front/src/utils/date.ts +++ b/front/src/utils/date.ts @@ -63,10 +63,9 @@ export const timeToMsSinceMidnight = ({ */ export const timeToLocaleStringRounded = (time: StartTime, locale: Intl.Locale): string => { if (time instanceof Duration) { - const totalMinutes = Math.round(time.total('minute')); - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; + return time + .round('minute') + .toLocaleString(locale, { style: 'digital', hours: '2-digit', secondsDisplay: 'auto' }); } const roundedTime = new Date( ...[ From 93825c318a2e1bf6bdaf95792550e6ea5c8e5343 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 11 Aug 2026 15:20:05 +0200 Subject: [PATCH 06/16] front: use Duration.toLocaleString() in formatSignedDelta() Signed-off-by: Simon Ser --- .../modules/timesStops/helpers/timePropagation.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/front/src/modules/timesStops/helpers/timePropagation.ts b/front/src/modules/timesStops/helpers/timePropagation.ts index 6b16bcf7c6f..fd20e3b78d8 100644 --- a/front/src/modules/timesStops/helpers/timePropagation.ts +++ b/front/src/modules/timesStops/helpers/timePropagation.ts @@ -42,16 +42,11 @@ const computeDeltaForPropagationMode = ( export const formatSignedDelta = (delta: Duration) => { const sign = delta.ms >= 0 ? '+' : '-'; - const absoluteDelta = delta.abs().round('second'); - const hours = Math.floor(absoluteDelta.total('hour')); - const minutes = Math.floor(absoluteDelta.total('minute')) % 60; - const seconds = Math.floor(absoluteDelta.total('second')) % 60; - - const hoursLabel = hours.toString().padStart(2, '0'); - const minutesLabel = minutes.toString().padStart(2, '0'); - const secondsLabel = seconds.toString().padStart(2, '0'); - - return `${sign}${hoursLabel}:${minutesLabel}:${secondsLabel}`; + const label = delta + .abs() + .round('second') + .toLocaleString(undefined, { style: 'digital', hours: '2-digit' }); + return `${sign}${label}`; }; export const formatPropagationDeltaLabelByMode = ( From 81dc8a413aafa719cb1866939db9821b145a09ac Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 11 Aug 2026 15:29:49 +0200 Subject: [PATCH 07/16] front: use Date.toLocaleTimeString() in TimesStopsTable formatLocalTime() is not suitable for presenting a time value to a human: it's meant for ``. Use toLocaleTimeString() instead. Signed-off-by: Simon Ser --- front/src/modules/timesStops/TimesStopsTable.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/front/src/modules/timesStops/TimesStopsTable.tsx b/front/src/modules/timesStops/TimesStopsTable.tsx index ac5dd208832..18cde0ff424 100644 --- a/front/src/modules/timesStops/TimesStopsTable.tsx +++ b/front/src/modules/timesStops/TimesStopsTable.tsx @@ -18,7 +18,7 @@ import { useTranslation } from 'react-i18next'; import type { ReceptionSignal } from 'common/api/osrdEditoastApi'; import { SkeletonLoader } from 'common/Loaders'; import { NO_POWER_RESTRICTION } from 'modules/powerRestriction/consts'; -import { formatLocalTime, useDateTimeLocale } from 'utils/date'; +import { useDateTimeLocale } from 'utils/date'; import type { Duration } from 'utils/duration'; import { calculateTimeDifferenceInDays } from 'utils/timeManipulation'; @@ -65,6 +65,13 @@ declare module '@tanstack/react-table' { } } +const formatTime = (date: Date, locale: Intl.Locale) => + date.toLocaleTimeString(locale, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + /** * Get the reference date for arrival editing. * The TimeCell bumps any typed time that falls before this date to the next day, @@ -482,7 +489,9 @@ const TimesStopsTable = ({ return ; } const value = info.getValue(); - return {value ? formatLocalTime(value) : ''}; + return ( + {value ? formatTime(value, dateTimeLocale) : ''} + ); }; const returnCalculatedDepartureTimeCell = (info: CellContext) => { @@ -493,7 +502,7 @@ const TimesStopsTable = ({ const isEmpty = !value; return ( - {isEmpty ? '•' : formatLocalTime(value)} + {isEmpty ? '•' : formatTime(value, dateTimeLocale)} ); }; From 9b81d9e04e5bd5f78863d1ef221e35cca4b0ee27 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 11 Aug 2026 15:36:51 +0200 Subject: [PATCH 08/16] front: add onEnterKeyDown to DurationCell Will be useful for hourly timetables. Signed-off-by: Simon Ser --- front/src/modules/timesStops/DurationCell.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/front/src/modules/timesStops/DurationCell.tsx b/front/src/modules/timesStops/DurationCell.tsx index c4c5862d979..5d01ef5b2a2 100644 --- a/front/src/modules/timesStops/DurationCell.tsx +++ b/front/src/modules/timesStops/DurationCell.tsx @@ -397,13 +397,20 @@ export type DurationCellHandle = { type DurationCellProps = CellContext & Omit, 'onChange'> & { + onEnterKeyDown?: () => void; onCommit?: (seconds: number | null, propagationMode: StopPropagationMode) => void; disabled?: boolean; clearButtonTitle?: string; ref?: React.Ref; }; -const DurationCell = ({ disabled, clearButtonTitle, ref, ...props }: DurationCellProps) => { +const DurationCell = ({ + disabled, + clearButtonTitle, + ref, + onEnterKeyDown, + ...props +}: DurationCellProps) => { const { onCommit, getValue, row, table } = props || {}; const controlledValue = getValue(); const [state, dispatch] = useReducer(durationReducer, controlledValue, initialDurationState); @@ -474,6 +481,7 @@ const DurationCell = ({ disabled, clearButtonTitle, ref, ...props }: DurationCel e.preventDefault(); blurHandledRef.current = true; commit(); + onEnterKeyDown?.(); containerRef.current?.blur(); break; case 'Escape': From 040135cb14155c6203f02c68ea4a7b86440fc12d Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 11 Aug 2026 15:40:57 +0200 Subject: [PATCH 09/16] front: add onTabKeyDown to DurationCell Will be useful for hourly timetables. Signed-off-by: Simon Ser --- front/src/modules/timesStops/DurationCell.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/front/src/modules/timesStops/DurationCell.tsx b/front/src/modules/timesStops/DurationCell.tsx index 5d01ef5b2a2..05c8e9983f7 100644 --- a/front/src/modules/timesStops/DurationCell.tsx +++ b/front/src/modules/timesStops/DurationCell.tsx @@ -398,6 +398,7 @@ export type DurationCellHandle = { type DurationCellProps = CellContext & Omit, 'onChange'> & { onEnterKeyDown?: () => void; + onTabKeyDown?: (direction: 'forward' | 'backward') => boolean; onCommit?: (seconds: number | null, propagationMode: StopPropagationMode) => void; disabled?: boolean; clearButtonTitle?: string; @@ -409,6 +410,7 @@ const DurationCell = ({ clearButtonTitle, ref, onEnterKeyDown, + onTabKeyDown, ...props }: DurationCellProps) => { const { onCommit, getValue, row, table } = props || {}; @@ -473,7 +475,7 @@ const DurationCell = ({ containerRef.current?.blur(); }; - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: React.KeyboardEvent) => { if (!state.isEditing) return; switch (e.key) { @@ -490,6 +492,12 @@ const DurationCell = ({ dispatch({ type: 'CANCEL_EDITING', payload: controlledValue }); containerRef.current?.blur(); break; + case 'Tab': + if (onTabKeyDown?.(e.shiftKey ? 'backward' : 'forward')) { + e.preventDefault(); + e.currentTarget.blur(); + } + break; case 'ArrowLeft': e.preventDefault(); dispatch({ type: 'NAVIGATE', payload: 'left' }); From 47fb349f44974bc3fc1a2ba18d6f80efd5c01d47 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 11 Aug 2026 15:42:18 +0200 Subject: [PATCH 10/16] front: add disableClear to DurationCell Will be useful for hourly timetables. Signed-off-by: Simon Ser --- front/src/modules/timesStops/DurationCell.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/front/src/modules/timesStops/DurationCell.tsx b/front/src/modules/timesStops/DurationCell.tsx index 05c8e9983f7..2e79da6ec64 100644 --- a/front/src/modules/timesStops/DurationCell.tsx +++ b/front/src/modules/timesStops/DurationCell.tsx @@ -402,6 +402,7 @@ type DurationCellProps = CellContext & onCommit?: (seconds: number | null, propagationMode: StopPropagationMode) => void; disabled?: boolean; clearButtonTitle?: string; + disableClear?: boolean; ref?: React.Ref; }; @@ -411,6 +412,7 @@ const DurationCell = ({ ref, onEnterKeyDown, onTabKeyDown, + disableClear, ...props }: DurationCellProps) => { const { onCommit, getValue, row, table } = props || {}; @@ -583,7 +585,7 @@ const DurationCell = ({ /> Date: Tue, 11 Aug 2026 15:45:47 +0200 Subject: [PATCH 11/16] front: add prefillValue to DurationCell Will be useful for hourly timetables. Signed-off-by: Simon Ser --- front/src/modules/timesStops/DurationCell.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/front/src/modules/timesStops/DurationCell.tsx b/front/src/modules/timesStops/DurationCell.tsx index 2e79da6ec64..28ce670b8b9 100644 --- a/front/src/modules/timesStops/DurationCell.tsx +++ b/front/src/modules/timesStops/DurationCell.tsx @@ -397,6 +397,7 @@ export type DurationCellHandle = { type DurationCellProps = CellContext & Omit, 'onChange'> & { + prefillValue?: Duration | null; onEnterKeyDown?: () => void; onTabKeyDown?: (direction: 'forward' | 'backward') => boolean; onCommit?: (seconds: number | null, propagationMode: StopPropagationMode) => void; @@ -407,6 +408,7 @@ type DurationCellProps = CellContext & }; const DurationCell = ({ + prefillValue, disabled, clearButtonTitle, ref, @@ -446,8 +448,9 @@ const DurationCell = ({ const startEditing = (unit: ActiveUnit) => { if (disabled) return; - const isCreationMode = controlledValue === null; - const seconds = isCreationMode ? 0 : Math.round(controlledValue.total('second')); + const initialValue = controlledValue ?? prefillValue ?? Duration.zero; + const isCreationMode = controlledValue === null && prefillValue === null; + const seconds = Math.round(initialValue.total('second')); dispatch({ type: 'START_EDITING', payload: { seconds, unit: isCreationMode ? 'm' : unit, isCreationMode }, From f21404bedbbe61b16e7bd4a75eca752280a03784 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 11 Aug 2026 16:57:34 +0200 Subject: [PATCH 12/16] front: add digital prop to DurationCell DurationCell will be used to edit time values relative to the start of a timetable for hourly timetables. Signed-off-by: Simon Ser --- front/src/modules/timesStops/DurationCell.tsx | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/front/src/modules/timesStops/DurationCell.tsx b/front/src/modules/timesStops/DurationCell.tsx index 28ce670b8b9..711bedc256b 100644 --- a/front/src/modules/timesStops/DurationCell.tsx +++ b/front/src/modules/timesStops/DurationCell.tsx @@ -6,6 +6,7 @@ import { useLayoutEffect, useEffect, useImperativeHandle, + Fragment, type Dispatch, } from 'react'; @@ -344,9 +345,17 @@ type UnitDisplayProps = { dispatch: Dispatch; startEditing: (unit: ActiveUnit) => void; isEdited: boolean; + label?: string; }; -const UnitDisplay = ({ unit, state, dispatch, startEditing, isEdited }: UnitDisplayProps) => { +const UnitDisplay = ({ + unit, + state, + dispatch, + startEditing, + isEdited, + label = unit, +}: UnitDisplayProps) => { const u = state.units[unit]; const focused = state.isEditing && state.activeUnit === unit; const baseClass = 'duration-cell-digit'; @@ -386,7 +395,7 @@ const UnitDisplay = ({ unit, state, dispatch, startEditing, isEdited }: UnitDisp )} - {unit && {unit}} + {label && {label}} ); }; @@ -405,6 +414,8 @@ type DurationCellProps = CellContext & clearButtonTitle?: string; disableClear?: boolean; ref?: React.Ref; + /** Display the duration as a digital clock, e.g. "42:53:04" */ + digital?: boolean; }; const DurationCell = ({ @@ -415,6 +426,7 @@ const DurationCell = ({ onEnterKeyDown, onTabKeyDown, disableClear, + digital, ...props }: DurationCellProps) => { const { onCommit, getValue, row, table } = props || {}; @@ -565,15 +577,18 @@ const DurationCell = ({ {}} /> ) : ( <> - {UNITS.map((unit) => ( - + {UNITS.map((unit, index) => ( + + {digital && index > 0 ? ':' : null} + + ))} )} From d971824a9bb6c25cf933fcb8586272a4cc484815 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 11 Aug 2026 15:52:13 +0200 Subject: [PATCH 13/16] front: introduce StartTimeCell This component selects between TimeCell and DurationCell depending on the StartTime variant. Signed-off-by: Simon Ser --- .../src/modules/timesStops/StartTimeCell.tsx | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 front/src/modules/timesStops/StartTimeCell.tsx diff --git a/front/src/modules/timesStops/StartTimeCell.tsx b/front/src/modules/timesStops/StartTimeCell.tsx new file mode 100644 index 00000000000..18f2b9fa8e0 --- /dev/null +++ b/front/src/modules/timesStops/StartTimeCell.tsx @@ -0,0 +1,86 @@ +import type { CellContext } from '@tanstack/react-table'; + +import { type StartTime, Duration } from 'utils/duration'; + +import DurationCell, { type DurationCellHandle } from './DurationCell'; +import TimeCell, { type TimeCellHandle } from './TimeCell'; +import type { PropagationMode, TimesStopsRowNew } from './types'; + +type StartTimeCellProps = { + type: 'time' | 'duration'; + cellContext: CellContext; +} & React.InputHTMLAttributes & { + /** Reference date used as the calendar day base. If the entered time is before this date, the next day is assumed. */ + referenceDate?: Date; + /** When the cell is empty, pre-fill the input with this value when the user focuses to edit. */ + prefillValue?: StartTime | null; + /** Title for the clear button. */ + clearButtonTitle?: string; + /** Called after Enter validates the input. Use to move focus (e.g. to the cell below). */ + onEnterKeyDown?: () => void; + /** Called on Tab key to move focus to the next/previous editable time cell. */ + onTabKeyDown?: (direction: 'forward' | 'backward') => boolean; + onCommit?: (date: StartTime | null, propagationMode: PropagationMode) => void; + disableClear?: boolean; + ref?: React.Ref; + }; + +const StartTimeCell = ({ + type, + cellContext, + referenceDate, + prefillValue, + clearButtonTitle, + onEnterKeyDown, + onTabKeyDown, + onCommit, + disableClear, + ...props +}: StartTimeCellProps) => { + if (type === 'time') { + if (prefillValue && !(prefillValue instanceof Date)) { + throw new Error('prefillValue must be a Date'); + } + // Unfortunately, CellContext is a complicated type and cannot be narrowed + // down, so we use a cast here. + const timeCellContext = cellContext as CellContext; + return ( + + ); + } else { + if (prefillValue && !(prefillValue instanceof Duration)) { + throw new Error('prefillValue must be a Duration'); + } + // Unfortunately, CellContext is a complicated type and cannot be narrowed + // down, so we use a cast here. + const durationCellContext = cellContext as CellContext; + return ( + { + const dur = seconds !== null ? new Duration({ seconds }) : null; + onCommit?.(dur, propagationMode); + }} + disableClear={disableClear} + digital={true} + /> + ); + } +}; + +export default StartTimeCell; From cfa775b30b6fe1852f744108ae47b7b299798ab7 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Wed, 12 Aug 2026 14:53:02 +0200 Subject: [PATCH 14/16] front: add "day" unit support to Duration The Temporal API also supports this unit. Signed-off-by: Simon Ser --- front/src/utils/duration.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/front/src/utils/duration.ts b/front/src/utils/duration.ts index bf3da11572b..c096541dde0 100644 --- a/front/src/utils/duration.ts +++ b/front/src/utils/duration.ts @@ -11,6 +11,7 @@ const MICROSECOND_IN_MS = 0.001; const SECOND_IN_MS = 1000; const MINUTE_IN_MS = 60 * SECOND_IN_MS; const HOUR_IN_MS = 60 * MINUTE_IN_MS; +const DAY_IN_MS = 24 * HOUR_IN_MS; export const MAX_DURATION_MS = Number(I64_MAX) * MICROSECOND_IN_MS; // Database will not register anything above this value @@ -18,14 +19,20 @@ const UNIT_IN_MS = { second: SECOND_IN_MS, minute: MINUTE_IN_MS, hour: HOUR_IN_MS, + day: DAY_IN_MS, }; export class Duration { /** Number of milliseconds */ readonly ms: number; - constructor({ hours = 0, minutes = 0, seconds = 0, milliseconds = 0 }) { - this.ms = hours * HOUR_IN_MS + minutes * MINUTE_IN_MS + seconds * SECOND_IN_MS + milliseconds; + constructor({ days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0 }) { + this.ms = + days * DAY_IN_MS + + hours * HOUR_IN_MS + + minutes * MINUTE_IN_MS + + seconds * SECOND_IN_MS + + milliseconds; } static zero = new Duration({}); @@ -84,7 +91,7 @@ export class Duration { return new Duration({ milliseconds: Math.abs(this.ms) }); } - round(smallestUnit: 'second' | 'minute' | 'hour') { + round(smallestUnit: 'second' | 'minute' | 'hour' | 'day') { return new Duration({ milliseconds: Math.round(this.total(smallestUnit)) * UNIT_IN_MS[smallestUnit], }); @@ -93,7 +100,7 @@ export class Duration { /** * Computes the number of units of time that a duration represents. */ - total(unit: 'second' | 'minute' | 'hour'): number { + total(unit: 'second' | 'minute' | 'hour' | 'day'): number { return this.ms / UNIT_IN_MS[unit]; } From 56707cfc4b0027242b985bf5003f25f4f5a4c12a Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Wed, 12 Aug 2026 14:53:57 +0200 Subject: [PATCH 15/16] front: drop calculateTimeDifferenceInDays() Instead, use the Duration class. This will simplify hourly timetable support in upcoming commits. Signed-off-by: Simon Ser --- .../modules/timesStops/TimesStopsTable.tsx | 8 +++---- front/src/modules/timesStops/helpers/utils.ts | 3 +++ .../utils/__tests__/timeManipulation.spec.ts | 21 ------------------- front/src/utils/timeManipulation.ts | 6 ------ 4 files changed, 7 insertions(+), 31 deletions(-) delete mode 100644 front/src/utils/__tests__/timeManipulation.spec.ts diff --git a/front/src/modules/timesStops/TimesStopsTable.tsx b/front/src/modules/timesStops/TimesStopsTable.tsx index 18cde0ff424..2b28f03c021 100644 --- a/front/src/modules/timesStops/TimesStopsTable.tsx +++ b/front/src/modules/timesStops/TimesStopsTable.tsx @@ -19,12 +19,11 @@ import type { ReceptionSignal } from 'common/api/osrdEditoastApi'; import { SkeletonLoader } from 'common/Loaders'; import { NO_POWER_RESTRICTION } from 'modules/powerRestriction/consts'; import { useDateTimeLocale } from 'utils/date'; -import type { Duration } from 'utils/duration'; -import { calculateTimeDifferenceInDays } from 'utils/timeManipulation'; +import { Duration } from 'utils/duration'; import DurationCell, { type DurationCellHandle } from './DurationCell'; import type { PowerRestrictionBlockInfo } from './helpers/powerRestrictionIncompatibility'; -import { onStopSignalToReceptionSignal } from './helpers/utils'; +import { onStopSignalToReceptionSignal, truncateDateToDay } from './helpers/utils'; import MarginCell from './MarginCell'; import TimeCell, { type TimeCellHandle } from './TimeCell'; import type { MarginValue, PropagationMode, StopPropagationMode, TimesStopsRowNew } from './types'; @@ -700,7 +699,8 @@ const TimesStopsTable = ({ if (!row.original.pathStepId) return null; const arrival = row.original.computedArrival ?? row.original.requestedArrival; if (!arrival) return null; - return calculateTimeDifferenceInDays(startTime, arrival); + const diff = Duration.subtractDate(truncateDateToDay(arrival), truncateDateToDay(startTime)); + return diff.total('day'); }; const tableRows = table.getRowModel().rows; diff --git a/front/src/modules/timesStops/helpers/utils.ts b/front/src/modules/timesStops/helpers/utils.ts index 9a43c682097..8288472b879 100644 --- a/front/src/modules/timesStops/helpers/utils.ts +++ b/front/src/modules/timesStops/helpers/utils.ts @@ -32,6 +32,9 @@ export const truncateDateToSecond = (date: Date): Date => { return truncated; }; +export const truncateDateToDay = (date: Date): Date => + new Date(date.getFullYear(), date.getMonth(), date.getDate()); + export const formatSuggestedViasToRowVias = ( operationalPoints: SuggestedOP[], pathSteps: PathStep[], diff --git a/front/src/utils/__tests__/timeManipulation.spec.ts b/front/src/utils/__tests__/timeManipulation.spec.ts deleted file mode 100644 index 0147b9eaf64..00000000000 --- a/front/src/utils/__tests__/timeManipulation.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { calculateTimeDifferenceInDays } from 'utils/timeManipulation'; - -describe('calculateTimeDifferenceInDays', () => { - it('should handle 2 dates on the same day', () => { - expect( - calculateTimeDifferenceInDays(new Date(2024, 1, 1, 10), new Date(2024, 1, 1, 15)) - ).toEqual(0); - }); - it('should handle 2 dates not on the same day', () => { - expect( - calculateTimeDifferenceInDays(new Date(2024, 1, 1, 10), new Date(2024, 1, 4, 15)) - ).toEqual(3); - }); - it('should handle 2 dates not on the same day with less than a day in duration', () => { - expect( - calculateTimeDifferenceInDays(new Date(2024, 1, 1, 23), new Date(2024, 1, 2, 2)) - ).toEqual(1); - }); -}); diff --git a/front/src/utils/timeManipulation.ts b/front/src/utils/timeManipulation.ts index 7eccc5a49a5..1b72ec0749f 100644 --- a/front/src/utils/timeManipulation.ts +++ b/front/src/utils/timeManipulation.ts @@ -29,12 +29,6 @@ export function durationInSeconds(start: number, end: number) { return end > start ? end - start : end + SECONDS_IN_A_DAY - start; } -export function calculateTimeDifferenceInDays(datetime1: Date, datetime2: Date) { - const date1 = new Date(datetime1.getFullYear(), datetime1.getMonth(), datetime1.getDate()); - const date2 = new Date(datetime2.getFullYear(), datetime2.getMonth(), datetime2.getDate()); - return dayjs.duration(date2.getTime() - date1.getTime()).asDays(); -} - /** * converts a value in seconds to a time string "HH:MM:SS" */ From c6820e4490ddf5d002e370e0e95aa9f93a2f9ce8 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 11 Aug 2026 15:54:41 +0200 Subject: [PATCH 16/16] front: adapt times and stops table rows for hourly timetables Change types from Date to StartTime, using all of the toys introduced in previous commits. Signed-off-by: Simon Ser --- .../timesStops/TimeStopsTableWrapper.tsx | 33 ++++-- .../modules/timesStops/TimesStopsTable.tsx | 108 ++++++++++++------ .../helpers/__tests__/cellUpdate.spec.ts | 4 +- .../__tests__/stopDurationPropagation.spec.ts | 15 ++- .../helpers/__tests__/timePropagation.spec.ts | 51 ++++++--- .../modules/timesStops/helpers/cellUpdate.ts | 41 ++++--- .../helpers/stopDurationPropagation.ts | 14 ++- .../timesStops/helpers/timePropagation.ts | 70 ++++++++---- front/src/modules/timesStops/helpers/utils.ts | 23 ++-- .../hooks/useTimesStopsTableData.ts | 27 +++-- .../hooks/useUpdateTimesStopsTable.ts | 33 ++++-- front/src/modules/timesStops/types.ts | 20 ++-- 12 files changed, 284 insertions(+), 155 deletions(-) diff --git a/front/src/modules/timesStops/TimeStopsTableWrapper.tsx b/front/src/modules/timesStops/TimeStopsTableWrapper.tsx index 515f67c0965..af82650f466 100644 --- a/front/src/modules/timesStops/TimeStopsTableWrapper.tsx +++ b/front/src/modules/timesStops/TimeStopsTableWrapper.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; +import { useScenarioContext } from 'applications/operationalStudies/hooks/useScenarioContext'; import type { PathPropertiesFormatted } from 'applications/operationalStudies/types'; import type { CorePathfindingResultSuccess, @@ -9,7 +10,7 @@ import type { } from 'common/api/osrdEditoastApi'; import type { SimulationSummary, TrainScheduleWithDetails } from 'modules/trainSchedule/types'; import type { Train } from 'reducers/osrdconf/types'; -import { Duration, addDurationToDate } from 'utils/duration'; +import { Duration, type StartTime, addDurationToStartTime, startTimeToMs } from 'utils/duration'; import { computeOptimisticRow, propagationToEdits } from './helpers/cellUpdate'; import { computePowerRestrictionWarnings } from './helpers/powerRestrictionIncompatibility'; @@ -45,18 +46,18 @@ type TimeStopsTableWrapperProps = { }; const bumpMidnightCrossings = (rows: TimesStopsRowNew[]): TimesStopsRowNew[] => { - let lastArrival: Date | null = null; + let lastArrival: StartTime | null = null; return rows.map((row) => { if (row.requestedArrival === null) return row; if (lastArrival !== null && row.requestedArrival < lastArrival) { - const bumped = addDurationToDate(row.requestedArrival, ONE_DAY); + const bumped = addDurationToStartTime(row.requestedArrival, ONE_DAY); lastArrival = bumped; return { ...row, requestedArrival: bumped, requestedDeparture: row.requestedDeparture !== null - ? addDurationToDate(row.requestedDeparture, ONE_DAY) + ? addDurationToStartTime(row.requestedDeparture, ONE_DAY) : null, }; } @@ -78,6 +79,8 @@ const TimeStopsTableWrapper = ({ isSimulationDataLoading = false, rollingStock, }: TimeStopsTableWrapperProps) => { + const { scenario } = useScenarioContext(); + // Refs used to track simulation refresh after a user edit (see isAwaitingSimulation): // - preEditPathItemTimesRef: batch summary (simulatedPathItemTimes reference) // - isTrainSimulationPendingRef: all simulation queries (isSimulationDataLoading) @@ -134,7 +137,13 @@ const TimeStopsTableWrapper = ({ return bumpMidnightCrossings(copyRows); }, [rows, optimisticEdits]); - const startTime = useMemo(() => new Date(selectedTrain.start_time), [selectedTrain.start_time]); + const startTime = useMemo( + () => + scenario.timetable_type === 'CALENDAR' + ? new Date(selectedTrain.start_time) + : new Duration({ milliseconds: selectedTrain.start_time }), + [selectedTrain.start_time, scenario.timetable_type] + ); const availablePowerRestrictions = useMemo( () => Object.keys(rollingStock?.power_restrictions ?? {}), @@ -207,9 +216,11 @@ const TimeStopsTableWrapper = ({ }; // Origin arrival = start_time (not in schedule), so propagationToEdits misses it. - const computeOriginEdits = (updatedStartTime: Date): PendingEdit[] => { + const computeOriginEdits = (updatedStartTime: StartTime): PendingEdit[] => { const originRow = rows.at(0); - return originRow && originRow.requestedArrival?.getTime() !== updatedStartTime.getTime() + return originRow && + (!originRow.requestedArrival || + startTimeToMs(originRow.requestedArrival) !== startTimeToMs(updatedStartTime)) ? [{ rowId: originRow.id, field: 'requestedArrival', value: updatedStartTime }] : []; }; @@ -218,7 +229,7 @@ const TimeStopsTableWrapper = ({ singleEdit: PendingEdit, update: CellUpdate & { propagationMode: PropagationMode } ): PendingEdit[] => { - const propagationResult = propagateTime(update, selectedTrain); + const propagationResult = propagateTime(update, selectedTrain, scenario.timetable_type); if (!propagationResult) return [singleEdit]; const propagationEdits = propagationToEdits(propagationResult, rows); @@ -244,7 +255,7 @@ const TimeStopsTableWrapper = ({ singleEdit: PendingEdit, update: StopDurationUpdate ): PendingEdit[] => { - const propagationResult = propagateStopDuration(update, selectedTrain); + const propagationResult = propagateStopDuration(update, selectedTrain, scenario.timetable_type); if (!propagationResult) return [singleEdit]; const propagationEdits = propagationToEdits(propagationResult, rows); @@ -298,7 +309,7 @@ const TimeStopsTableWrapper = ({ const handleArrivalChange = ( row: TimesStopsRowNew, - arrival: Date | null, + arrival: StartTime | null, propagationMode: PropagationMode ) => { const singleEdit: PendingEdit = { rowId: row.id, field: 'requestedArrival', value: arrival }; @@ -315,7 +326,7 @@ const TimeStopsTableWrapper = ({ const handleDepartureChange = ( row: TimesStopsRowNew, - departure: Date | null, + departure: StartTime | null, propagationMode: PropagationMode ) => { const singleEdit: PendingEdit = { diff --git a/front/src/modules/timesStops/TimesStopsTable.tsx b/front/src/modules/timesStops/TimesStopsTable.tsx index 2b28f03c021..d30958b58d0 100644 --- a/front/src/modules/timesStops/TimesStopsTable.tsx +++ b/front/src/modules/timesStops/TimesStopsTable.tsx @@ -15,17 +15,19 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import cx from 'classnames'; import { useTranslation } from 'react-i18next'; +import { useScenarioContext } from 'applications/operationalStudies/hooks/useScenarioContext'; import type { ReceptionSignal } from 'common/api/osrdEditoastApi'; import { SkeletonLoader } from 'common/Loaders'; import { NO_POWER_RESTRICTION } from 'modules/powerRestriction/consts'; import { useDateTimeLocale } from 'utils/date'; -import { Duration } from 'utils/duration'; +import { type Duration, type StartTime, subtractStartTime } from 'utils/duration'; import DurationCell, { type DurationCellHandle } from './DurationCell'; import type { PowerRestrictionBlockInfo } from './helpers/powerRestrictionIncompatibility'; -import { onStopSignalToReceptionSignal, truncateDateToDay } from './helpers/utils'; +import { onStopSignalToReceptionSignal, truncateStartTimeToDay } from './helpers/utils'; import MarginCell from './MarginCell'; -import TimeCell, { type TimeCellHandle } from './TimeCell'; +import StartTimeCell from './StartTimeCell'; +import type { TimeCellHandle } from './TimeCell'; import type { MarginValue, PropagationMode, StopPropagationMode, TimesStopsRowNew } from './types'; declare module '@tanstack/react-table' { @@ -45,7 +47,7 @@ declare module '@tanstack/react-table' { powerRestrictionBlocks: Map; onArrivalChange: ( row: TimesStopsRowNew, - arrival: Date | null, + arrival: StartTime | null, propagationMode: PropagationMode ) => void; onStopDurationChange: ( @@ -55,7 +57,7 @@ declare module '@tanstack/react-table' { ) => void; onDepartureChange: ( row: TimesStopsRowNew, - departure: Date | null, + departure: StartTime | null, propagationMode: PropagationMode ) => void; onReceptionSignalChange: (row: TimesStopsRowNew, signal: ReceptionSignal | undefined) => void; @@ -64,12 +66,14 @@ declare module '@tanstack/react-table' { } } -const formatTime = (date: Date, locale: Intl.Locale) => - date.toLocaleTimeString(locale, { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); +const formatTime = (t: StartTime, locale: Intl.Locale) => + t instanceof Date + ? t.toLocaleTimeString(locale, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + : t.toLocaleString(locale, { style: 'digital', hours: '2-digit' }); /** * Get the reference date for arrival editing. @@ -79,23 +83,38 @@ const formatTime = (date: Date, locale: Intl.Locale) => const getArrivalReferenceDate = ( row: TimesStopsRowNew, allRows: TimesStopsRowNew[], - startTime: Date -): Date => { + startTime: StartTime +): Date | undefined => { + if (!(startTime instanceof Date)) return undefined; + const previousRow = allRows.findLast( (r) => r.opOnPathIndex < row.opOnPathIndex && (r.requestedDeparture || r.requestedArrival) ); if (!previousRow) return startTime; - return previousRow.requestedDeparture ?? previousRow.requestedArrival ?? startTime; + const refDate = previousRow.requestedDeparture ?? previousRow.requestedArrival ?? startTime; + if (!(refDate instanceof Date)) { + throw new Error('requestedDeparture and requestedArrival must be a Date'); + } + return refDate; }; /** * Get the reference date for departure editing. * Uses the current row's arrival time since departure must be after arrival. */ -const getDepartureReferenceDate = (row: TimesStopsRowNew, startTime: Date): Date => - row.requestedArrival ?? row.computedArrival ?? startTime; +const getDepartureReferenceDate = ( + row: TimesStopsRowNew, + startTime: StartTime +): Date | undefined => { + if (!(startTime instanceof Date)) return undefined; + const refDate = row.requestedArrival ?? row.computedArrival ?? startTime; + if (!(refDate instanceof Date)) { + throw new Error('requestedArrival and computedArrival must be a Date'); + } + return refDate; +}; /** * Check if the OP is a scheduled OP. @@ -106,7 +125,7 @@ const isScheduledOP = (row: TimesStopsRowNew): boolean => type TimesStopsTableProps = { rows: TimesStopsRowNew[]; - startTime: Date; + startTime: StartTime; isValid: boolean; isComputedDataPending?: boolean; availablePowerRestrictions: string[]; @@ -114,7 +133,7 @@ type TimesStopsTableProps = { powerRestrictionBlocks?: Map; onArrivalChange: ( row: TimesStopsRowNew, - arrival: Date | null, + arrival: StartTime | null, propagationMode: PropagationMode ) => void; onStopDurationChange: ( @@ -124,7 +143,7 @@ type TimesStopsTableProps = { ) => void; onDepartureChange: ( row: TimesStopsRowNew, - departure: Date | null, + departure: StartTime | null, propagationMode: PropagationMode ) => void; onReceptionSignalChange: (row: TimesStopsRowNew, signal: ReceptionSignal | undefined) => void; @@ -168,9 +187,12 @@ const TimesStopsTable = ({ }: TimesStopsTableProps) => { const { t } = useTranslation('translation', { keyPrefix: 'timeStopTable' }); const dateTimeLocale = useDateTimeLocale(); + const { scenario } = useScenarioContext(); const scheduleNotHonored = rows.some((row) => row.stepStatus === 'scheduleNotHonored'); const cellHandlesRef = useRef>(new Map()); const cellTabOrderRef = useRef>(new Map()); + const startTimeCellType: 'time' | 'duration' = + scenario.timetable_type === 'CALENDAR' ? 'time' : 'duration'; const registerTimeCellRef = useCallback( (rowIndex: number, columnId: string) => (handle: TabbableCellHandle | null) => { @@ -337,12 +359,13 @@ const TimesStopsTable = ({ ); }; - const returnDepartureTimeCell = (info: CellContext) => { + const returnDepartureTimeCell = (info: CellContext) => { const row = info.row.original; return ( - ) => { + const returnArrivalTimeCell = (info: CellContext) => { const row = info.row.original; const { allRows, onArrivalChange: onArrival } = info.table.options.meta!; return ( - ) => { + const returnCalculatedArrivalTimeCell = ( + info: CellContext + ) => { if (info.table.options.meta!.isComputedDataPending) { return ; } @@ -493,7 +519,9 @@ const TimesStopsTable = ({ ); }; - const returnCalculatedDepartureTimeCell = (info: CellContext) => { + const returnCalculatedDepartureTimeCell = ( + info: CellContext + ) => { if (info.table.options.meta!.isComputedDataPending) { return ; } @@ -699,7 +727,10 @@ const TimesStopsTable = ({ if (!row.original.pathStepId) return null; const arrival = row.original.computedArrival ?? row.original.requestedArrival; if (!arrival) return null; - const diff = Duration.subtractDate(truncateDateToDay(arrival), truncateDateToDay(startTime)); + const diff = subtractStartTime( + truncateStartTimeToDay(arrival), + truncateStartTimeToDay(startTime) + ); return diff.total('day'); }; @@ -803,6 +834,19 @@ const TimesStopsTable = ({ const prevDayOffset = rowIndex > 0 ? effectiveDayOffsets[rowIndex - 1] : 0; const hasDayChanged = dayOffset > prevDayOffset; + let dayChangeLabel = null; + if (hasDayChanged) { + if (rowArrivalDate instanceof Date) { + dayChangeLabel = rowArrivalDate.toLocaleDateString(dateTimeLocale, { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + } else { + dayChangeLabel = t('dayCounter', { count: dayOffset }); + } + } + const translateY = (virtualItems.at(0)?.start ?? 0) - virtualizer.options.scrollMargin; return ( @@ -820,13 +864,7 @@ const TimesStopsTable = ({
- - {rowArrivalDate?.toLocaleDateString(dateTimeLocale, { - day: 'numeric', - month: 'long', - year: 'numeric', - })} - + {dayChangeLabel}
diff --git a/front/src/modules/timesStops/helpers/__tests__/cellUpdate.spec.ts b/front/src/modules/timesStops/helpers/__tests__/cellUpdate.spec.ts index 19c01a6bbcc..a29bb09332e 100644 --- a/front/src/modules/timesStops/helpers/__tests__/cellUpdate.spec.ts +++ b/front/src/modules/timesStops/helpers/__tests__/cellUpdate.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { Duration } from 'utils/duration'; +import { Duration, type StartTime, startTimeToMs } from 'utils/duration'; import { applyScheduleEdit, type ScheduleState } from '../cellUpdate'; @@ -13,7 +13,7 @@ const _30MIN = new Duration({ minutes: 30 }); const _60MIN = new Duration({ minutes: 60 }); /** Helper to compare dates by their timestamp */ -const t = (d: Date | null) => d?.getTime() ?? null; +const t = (d: StartTime | null) => d ? startTimeToMs(d) : null; /** Helper to compare durations by their ms */ const ms = (d: Duration | null) => d?.ms ?? null; diff --git a/front/src/modules/timesStops/helpers/__tests__/stopDurationPropagation.spec.ts b/front/src/modules/timesStops/helpers/__tests__/stopDurationPropagation.spec.ts index a10a93cbac6..695dd4e4fee 100644 --- a/front/src/modules/timesStops/helpers/__tests__/stopDurationPropagation.spec.ts +++ b/front/src/modules/timesStops/helpers/__tests__/stopDurationPropagation.spec.ts @@ -62,7 +62,8 @@ describe('propagateStopDuration', () => { value: 900, propagationMode: 'atThisWaypoint', }, - train + train, + 'CALENDAR' ) ).toBeUndefined(); expect( @@ -73,7 +74,8 @@ describe('propagateStopDuration', () => { value: 900, propagationMode: 'toDestination', }, - train + train, + 'CALENDAR' ) ).toBeUndefined(); }); @@ -83,7 +85,8 @@ describe('propagateStopDuration', () => { const row = makeRow('PT5M'); const result = propagateStopDuration( { row, field: 'stopDuration', value: 900, propagationMode: 'toDestination' }, // +10min - train + train, + 'CALENDAR' ); expect(result).toBeDefined(); const { updatedSchedule, updatedStartTime } = result!; @@ -102,7 +105,8 @@ describe('propagateStopDuration', () => { const row = makeRow('PT5M'); const result = propagateStopDuration( { row, field: 'stopDuration', value: 600, propagationMode: 'fromDeparture' }, // +5min - train + train, + 'CALENDAR' ); expect(result).toBeDefined(); const { updatedSchedule, updatedStartTime } = result!; @@ -120,7 +124,8 @@ describe('propagateStopDuration', () => { const row = makeRow(null); const result = propagateStopDuration( { row, field: 'stopDuration', value: 600, propagationMode: 'fromDeparture' }, // +10min - train + train, + 'CALENDAR' ); expect(result).toBeDefined(); const { updatedSchedule, updatedStartTime } = result!; diff --git a/front/src/modules/timesStops/helpers/__tests__/timePropagation.spec.ts b/front/src/modules/timesStops/helpers/__tests__/timePropagation.spec.ts index bf1d2f29632..bdfe53a136b 100644 --- a/front/src/modules/timesStops/helpers/__tests__/timePropagation.spec.ts +++ b/front/src/modules/timesStops/helpers/__tests__/timePropagation.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import type { Train } from 'reducers/osrdconf/types'; -import { addDurationToDate, Duration } from 'utils/duration'; +import { addDurationToStartTime, Duration, type StartTime } from 'utils/duration'; import type { PropagationMode, TimesStopsRowNew } from '../../types'; import { @@ -42,8 +42,8 @@ const makeTrain = (): Train => }) as unknown as Train; /** Converts a start time and a duration offset (e.g. 'PT30M') into an absolute arrival Date. */ -const toComputedArrival = (startTime: Date, offsetIso: string): Date => - addDurationToDate(startTime, Duration.parse(offsetIso)); +const toComputedArrival = (startTime: Date | Duration, offsetIso: string): StartTime => + addDurationToStartTime(startTime, Duration.parse(offsetIso)); // HH:mm:ss diff = 29s, but raw ms diff = 29 700ms → would round up to 30s without the fix it('formatPropagationDeltaLabelByMode: ignores sub-second precision', () => { @@ -78,7 +78,8 @@ describe('Scenario 1 — +10 min at OP11', () => { expect( propagateTime( { field: 'requestedArrival', row, value: _18H40, propagationMode: 'atThisWaypoint' }, - train + train, + 'CALENDAR' ) ).toBeUndefined(); }); @@ -86,7 +87,8 @@ describe('Scenario 1 — +10 min at OP11', () => { it('toDestination — should leave start_time unchanged, OP11 → 18:40, OP17 → 19:00', () => { const result = propagateTime( { field: 'requestedArrival', row, value: _18H40, propagationMode: 'toDestination' }, - train + train, + 'CALENDAR' ); expect(result).toBeDefined(); const { updatedStartTime, updatedSchedule } = result!; @@ -102,7 +104,8 @@ describe('Scenario 1 — +10 min at OP11', () => { it('fromDeparture — should shift start_time → 18:10, OP11 → 18:40, OP17 stays at 18:50', () => { const result = propagateTime( { field: 'requestedArrival', row, value: _18H40, propagationMode: 'fromDeparture' }, - train + train, + 'CALENDAR' ); expect(result).toBeDefined(); const { updatedStartTime, updatedSchedule } = result!; @@ -118,7 +121,8 @@ describe('Scenario 1 — +10 min at OP11', () => { it('shiftAllWaypoints — should shift start_time → 18:10, all offsets unchanged, entire train +10min', () => { const result = propagateTime( { field: 'requestedArrival', row, value: _18H40, propagationMode: 'shiftAllWaypoints' }, - train + train, + 'CALENDAR' ); expect(result).toBeDefined(); const { updatedStartTime, updatedSchedule } = result!; @@ -160,7 +164,8 @@ describe('Scenario 2 — +30 min at OP11', () => { expect( propagateTime( { field: 'requestedArrival', row, value: _19H00, propagationMode: 'atThisWaypoint' }, - train + train, + 'CALENDAR' ) ).toBeUndefined(); }); @@ -168,7 +173,8 @@ describe('Scenario 2 — +30 min at OP11', () => { it('toDestination — should leave start_time unchanged, OP11 → 19:00, OP17 → 19:20', () => { const result = propagateTime( { field: 'requestedArrival', row, value: _19H00, propagationMode: 'toDestination' }, - train + train, + 'CALENDAR' ); expect(result).toBeDefined(); const { updatedStartTime, updatedSchedule } = result!; @@ -184,7 +190,8 @@ describe('Scenario 2 — +30 min at OP11', () => { it('fromDeparture — should shift start_time → 18:30, OP11 → 19:00, OP17 midnight crossing → next day 18:50', () => { const result = propagateTime( { field: 'requestedArrival', row, value: _19H00, propagationMode: 'fromDeparture' }, - train + train, + 'CALENDAR' ); expect(result).toBeDefined(); const { updatedStartTime, updatedSchedule } = result!; @@ -202,7 +209,8 @@ describe('Scenario 2 — +30 min at OP11', () => { it('shiftAllWaypoints — should shift start_time → 18:30, all offsets unchanged, entire train +30min', () => { const result = propagateTime( { field: 'requestedArrival', row, value: _19H00, propagationMode: 'shiftAllWaypoints' }, - train + train, + 'CALENDAR' ); expect(result).toBeDefined(); const { updatedStartTime, updatedSchedule } = result!; @@ -262,7 +270,8 @@ describe('Scenario 3 — -40 min at OP11', () => { value: _17H50_MIDNIGHT_CROSSING, propagationMode: 'atThisWaypoint', }, - train + train, + 'CALENDAR' ) ).toBeUndefined(); }); @@ -275,7 +284,8 @@ describe('Scenario 3 — -40 min at OP11', () => { value: _17H50_MIDNIGHT_CROSSING, propagationMode: 'toDestination', }, - train + train, + 'CALENDAR' ); expect(result).toBeDefined(); const { updatedStartTime, updatedSchedule } = result!; @@ -300,7 +310,8 @@ describe('Scenario 3 — -40 min at OP11', () => { value: _17H50_MIDNIGHT_CROSSING, propagationMode: 'fromDeparture', }, - train + train, + 'CALENDAR' ); expect(result).toBeDefined(); const { updatedStartTime, updatedSchedule } = result!; @@ -321,7 +332,8 @@ describe('Scenario 3 — -40 min at OP11', () => { value: _17H50_MIDNIGHT_CROSSING, propagationMode: 'shiftAllWaypoints', }, - train + train, + 'CALENDAR' ); expect(result).toBeDefined(); const { updatedStartTime, updatedSchedule } = result!; @@ -385,7 +397,8 @@ describe('Scenario 4 — +40 min at origin (OP1)', () => { value: _18H40, propagationMode: 'atThisWaypoint', }, - train + train, + 'CALENDAR' ); expect(result).toBeDefined(); const { updatedStartTime, updatedSchedule } = result!; @@ -411,7 +424,8 @@ describe('Scenario 4 — +40 min at origin (OP1)', () => { value: _18H40, propagationMode: 'fromDeparture', }, - train + train, + 'CALENDAR' ) ).toBeUndefined(); }); @@ -422,7 +436,8 @@ describe('Scenario 4 — +40 min at origin (OP1)', () => { (mode) => { const result = propagateTime( { field: 'requestedArrival', row: originRow, value: _18H40, propagationMode: mode }, - train + train, + 'CALENDAR' ); expect(result).toBeDefined(); const { updatedStartTime, updatedSchedule } = result!; diff --git a/front/src/modules/timesStops/helpers/cellUpdate.ts b/front/src/modules/timesStops/helpers/cellUpdate.ts index cd25633fd3b..8ef0f8fc6dc 100644 --- a/front/src/modules/timesStops/helpers/cellUpdate.ts +++ b/front/src/modules/timesStops/helpers/cellUpdate.ts @@ -8,10 +8,17 @@ import type { } from 'common/api/osrdEditoastApi'; import type { Train } from 'reducers/osrdconf/types'; import { addElementAtIndex } from 'utils/array'; -import { Duration } from 'utils/duration'; +import { + Duration, + type StartTime, + addDurationToStartTime, + subtractStartTime, + subtractDurationFromStartTime, + startTimeToMs, +} from 'utils/duration'; import type { OptimisticEdit, PendingEdit, TimesStopsRowNew } from '../types'; -import { receptionSignalToSignalBooleans, truncateDateToSecond } from './utils'; +import { receptionSignalToSignalBooleans, truncateStartTimeToSecond } from './utils'; /** Compute the insertion index for a new PathStep using row opOnPathIndex values. */ const computeInsertIndex = ( @@ -51,12 +58,12 @@ export const upsertPathStep = ( }; /** Compute departure from arrival and stop duration. */ -const computeDeparture = (arrival: Date | null, stop: Duration | null): Date | null => - arrival !== null && stop !== null ? new Date(arrival.getTime() + stop.ms) : null; +const computeDeparture = (arrival: StartTime | null, stop: Duration | null): StartTime | null => + arrival !== null && stop !== null ? addDurationToStartTime(arrival, stop) : null; /** State of a stop's schedule in display format (Date / Duration). */ export type ScheduleState = { - arrival: Date | null; + arrival: StartTime | null; stop: Duration | null; }; @@ -71,7 +78,7 @@ export type ScheduleState = { export const applyScheduleEdit = ( current: ScheduleState, edit: Exclude -): ScheduleState & { departure: Date | null } => { +): ScheduleState & { departure: StartTime | null } => { const { arrival, stop } = current; switch (edit.field) { @@ -112,13 +119,13 @@ export const applyScheduleEdit = ( // TimeCell already ensured departure >= arrival by using arrival as referenceDate return { arrival, - stop: new Duration({ milliseconds: newDeparture.getTime() - arrival.getTime() }), + stop: subtractStartTime(newDeparture, arrival), departure: newDeparture, }; } // No arrival → arrival = departure - existingStop return { - arrival: new Date(newDeparture.getTime() - (stop?.ms ?? 0)), + arrival: subtractDurationFromStartTime(newDeparture, stop ?? Duration.zero), stop, departure: newDeparture, }; @@ -139,13 +146,13 @@ export const applyScheduleEdit = ( */ export const scheduleStateToApiFields = ( state: ScheduleState, - startTime: Date + startTime: StartTime ): { arrival: string | null; stop_for: string | null } => ({ arrival: state.arrival !== null - ? Duration.subtractDate( - truncateDateToSecond(state.arrival), - truncateDateToSecond(startTime) + ? subtractStartTime( + truncateStartTimeToSecond(state.arrival), + truncateStartTimeToSecond(startTime) ).toISOString() : null, stop_for: state.stop !== null ? state.stop.toISOString() : null, @@ -233,16 +240,18 @@ export const computeOptimisticRow = ( * Each affected row gets a new requestedArrival computed from updatedSchedule + updatedStartTime. */ export const propagationToEdits = ( - result: { updatedSchedule: ScheduleItem[]; updatedStartTime: Date }, + result: { updatedSchedule: ScheduleItem[]; updatedStartTime: StartTime }, rows: TimesStopsRowNew[] ): PendingEdit[] => rows.flatMap((row) => { const item = result.updatedSchedule.find((s) => s.at === row.pathStepId); if (!item?.arrival) return []; - const newArrival = new Date( - result.updatedStartTime.getTime() + Duration.parse(item.arrival).ms + const newArrival = addDurationToStartTime( + result.updatedStartTime, + Duration.parse(item.arrival) ); - if (newArrival.getTime() === row.requestedArrival?.getTime()) return []; + if (row.requestedArrival && startTimeToMs(newArrival) === startTimeToMs(row.requestedArrival)) + return []; return [{ rowId: row.id, field: 'requestedArrival' as const, value: newArrival }]; }); diff --git a/front/src/modules/timesStops/helpers/stopDurationPropagation.ts b/front/src/modules/timesStops/helpers/stopDurationPropagation.ts index 03a31392aaa..a8bc6bd7c40 100644 --- a/front/src/modules/timesStops/helpers/stopDurationPropagation.ts +++ b/front/src/modules/timesStops/helpers/stopDurationPropagation.ts @@ -1,6 +1,6 @@ -import type { ScheduleItem } from 'common/api/osrdEditoastApi'; +import type { ScheduleItem, TimetableType } from 'common/api/osrdEditoastApi'; import type { Train } from 'reducers/osrdconf/types'; -import { Duration, subtractDurationFromDate } from 'utils/duration'; +import { Duration, subtractDurationFromStartTime } from 'utils/duration'; import type { StopDurationUpdate } from '../types'; import { insertScheduleItemInOrder } from './cellUpdate'; @@ -22,7 +22,8 @@ export const formatStopDurationDeltaLabel = ( */ export const propagateStopDuration = ( update: StopDurationUpdate, - selectedTrain: Train + selectedTrain: Train, + timetableType: TimetableType ): PropagationResult | undefined => { // Clearing the duration falls through to the generic single-row edit path, regardless of mode. if ( @@ -46,7 +47,10 @@ export const propagateStopDuration = ( const currentSchedule = selectedTrain.schedule ?? []; const editedItem = currentSchedule.find((item) => item.at === pathStepId); const editedOffset = editedItem?.arrival ? Duration.parse(editedItem.arrival) : null; - const currentStartTime = new Date(selectedTrain.start_time); + const currentStartTime = + timetableType === 'CALENDAR' + ? new Date(selectedTrain.start_time) + : new Duration({ milliseconds: selectedTrain.start_time }); // Shift every scheduled arrival after the edited point by +delta, in path order. Bump +24h // if a shifted arrival ends up before the previous one. @@ -81,7 +85,7 @@ export const propagateStopDuration = ( const updatedStartTime = update.propagationMode === 'fromDeparture' - ? subtractDurationFromDate(currentStartTime, delta) + ? subtractDurationFromStartTime(currentStartTime, delta) : currentStartTime; return { diff --git a/front/src/modules/timesStops/helpers/timePropagation.ts b/front/src/modules/timesStops/helpers/timePropagation.ts index fd20e3b78d8..cb15a698699 100644 --- a/front/src/modules/timesStops/helpers/timePropagation.ts +++ b/front/src/modules/timesStops/helpers/timePropagation.ts @@ -1,43 +1,50 @@ -import type { PathItem, ScheduleItem } from 'common/api/osrdEditoastApi'; +import type { PathItem, ScheduleItem, TimetableType } from 'common/api/osrdEditoastApi'; import type { Train } from 'reducers/osrdconf/types'; -import { addDurationToDate, Duration } from 'utils/duration'; +import { + Duration, + type StartTime, + addDurationToStartTime, + subtractStartTime, +} from 'utils/duration'; import type { ArrivalUpdate, CellUpdate, PropagationMode } from '../types'; -import { truncateDateToSecond } from './utils'; +import { truncateStartTimeToSecond } from './utils'; export const ONE_DAY = new Duration({ hours: 24 }); export type PropagationResult = { updatedPath: PathItem[]; updatedSchedule: ScheduleItem[]; - updatedStartTime: Date; + updatedStartTime: StartTime; }; const isOriginArrivalUpdate = (update: CellUpdate): update is ArrivalUpdate => update.field === 'requestedArrival' && update.row.opOnPathIndex === 0; -const toHmsDuration = (date: Date) => - new Duration({ - hours: date.getHours(), - minutes: date.getMinutes(), - seconds: date.getSeconds(), - }); +const toHmsDuration = (date: StartTime) => + date instanceof Date + ? new Duration({ + hours: date.getHours(), + minutes: date.getMinutes(), + seconds: date.getSeconds(), + }) + : new Duration({ seconds: Math.floor(date.total('second')) }); // Delta based on HH:mm:ss only. Ignores the calendar day. -const computeDelta = (oldValue: Date | null, newValue: Date | null): Duration | null => { +const computeDelta = (oldValue: StartTime | null, newValue: StartTime | null): Duration | null => { if (!oldValue || !newValue) return null; return toHmsDuration(newValue).sub(toHmsDuration(oldValue)); }; const computeDeltaForPropagationMode = ( - oldValue: Date | null, - newValue: Date | null, + oldValue: StartTime | null, + newValue: StartTime | null, mode: PropagationMode ): Duration | null => mode === 'shiftAllWaypoints' || mode === 'fromDeparture' ? computeDelta(oldValue, newValue) : oldValue && newValue - ? Duration.subtractDate(truncateDateToSecond(newValue), truncateDateToSecond(oldValue)) + ? subtractStartTime(truncateStartTimeToSecond(newValue), truncateStartTimeToSecond(oldValue)) : null; export const formatSignedDelta = (delta: Duration) => { @@ -75,7 +82,8 @@ const propagateFromEditedPoint = ( delta: Duration, editedPathStepId: string, selectedTrain: Train, - direction: 'fromDeparture' | 'toDestination' + direction: 'fromDeparture' | 'toDestination', + timetableType: TimetableType ): PropagationResult | undefined => { // Delta strategy by direction: // - fromDeparture: compare time-of-day only @@ -83,10 +91,15 @@ const propagateFromEditedPoint = ( const editedPathIndex = selectedTrain.path.findIndex((step) => step.id === editedPathStepId); if (editedPathIndex < 0) return undefined; - const currentStartTime = new Date(selectedTrain.start_time); + const currentStartTime = + timetableType === 'CALENDAR' + ? new Date(selectedTrain.start_time) + : new Duration({ milliseconds: selectedTrain.start_time }); // For fromDeparture: the train's start time shifts by delta. For toDestination: it stays the same. const newStartTime = - direction === 'fromDeparture' ? addDurationToDate(currentStartTime, delta) : currentStartTime; + direction === 'fromDeparture' + ? addDurationToStartTime(currentStartTime, delta) + : currentStartTime; // Compute shifted offsets for all affected items, sorted by path order. const affectedItems = Iterator.from(selectedTrain.schedule ?? []) @@ -138,13 +151,17 @@ const propagateFromEditedPoint = ( const propagateShiftAll = ( delta: Duration, - selectedTrain: Train + selectedTrain: Train, + timetableType: TimetableType ): PropagationResult | undefined => { - const currentStartTime = new Date(selectedTrain.start_time); + const currentStartTime = + timetableType === 'CALENDAR' + ? new Date(selectedTrain.start_time) + : new Duration({ milliseconds: selectedTrain.start_time }); return { updatedPath: selectedTrain.path, updatedSchedule: selectedTrain.schedule ?? [], - updatedStartTime: addDurationToDate(currentStartTime, delta), + updatedStartTime: addDurationToStartTime(currentStartTime, delta), }; }; @@ -185,7 +202,8 @@ export const adjustFollowingWaypointsForMidnight = ( export const propagateTime = ( update: CellUpdate, - selectedTrain: Train + selectedTrain: Train, + timetableType: TimetableType ): PropagationResult | undefined => { if (update.field !== 'requestedArrival' && update.field !== 'requestedDeparture') return undefined; @@ -203,9 +221,9 @@ export const propagateTime = ( if (delta === null) return undefined; if (isOriginUpdate || update.propagationMode === 'shiftAllWaypoints') { - if (!isOriginUpdate) return propagateShiftAll(delta, selectedTrain); + if (!isOriginUpdate) return propagateShiftAll(delta, selectedTrain, timetableType); if (isShiftAllPropagation || update.propagationMode === 'toDestination') - return propagateShiftAll(delta, selectedTrain); + return propagateShiftAll(delta, selectedTrain, timetableType); // atThisWaypoint at origin = only move start_time. Following offsets are compensated so // their absolute times stay the same — which is exactly what fromDeparture does. if (update.propagationMode === 'atThisWaypoint') @@ -213,7 +231,8 @@ export const propagateTime = ( delta, update.row.pathStepId!, selectedTrain, - 'fromDeparture' + 'fromDeparture', + timetableType ); return undefined; } @@ -223,6 +242,7 @@ export const propagateTime = ( delta, update.row.pathStepId, selectedTrain, - update.propagationMode + update.propagationMode, + timetableType ); }; diff --git a/front/src/modules/timesStops/helpers/utils.ts b/front/src/modules/timesStops/helpers/utils.ts index 8288472b879..8ab43a70dca 100644 --- a/front/src/modules/timesStops/helpers/utils.ts +++ b/front/src/modules/timesStops/helpers/utils.ts @@ -13,7 +13,7 @@ import type { import type { TimeString } from 'common/types'; import type { SuggestedOP } from 'modules/trainSchedule/types'; import type { PathStep } from 'reducers/osrdconf/types'; -import { Duration } from 'utils/duration'; +import { Duration, type StartTime } from 'utils/duration'; import { msToS } from 'utils/physics'; import { NO_BREAK_SPACE } from 'utils/strings'; import { @@ -26,14 +26,23 @@ import { import { marginRegExValidation, MarginUnit } from '../consts'; import { TableType, type TimeExtraDays, type TimesStopsInputRow } from '../types'; -export const truncateDateToSecond = (date: Date): Date => { - const truncated = new Date(date); - truncated.setMilliseconds(0); - return truncated; +export const truncateStartTimeToSecond = (date: StartTime): StartTime => { + if (date instanceof Date) { + const truncated = new Date(date); + truncated.setMilliseconds(0); + return truncated; + } else { + return new Duration({ seconds: Math.floor(date.total('second')) }); + } }; -export const truncateDateToDay = (date: Date): Date => - new Date(date.getFullYear(), date.getMonth(), date.getDate()); +export const truncateStartTimeToDay = (date: StartTime): StartTime => { + if (date instanceof Date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); + } else { + return new Duration({ days: Math.floor(date.total('day')) }); + } +}; export const formatSuggestedViasToRowVias = ( operationalPoints: SuggestedOP[], diff --git a/front/src/modules/timesStops/hooks/useTimesStopsTableData.ts b/front/src/modules/timesStops/hooks/useTimesStopsTableData.ts index 0800ad45951..42fd377fd49 100644 --- a/front/src/modules/timesStops/hooks/useTimesStopsTableData.ts +++ b/front/src/modules/timesStops/hooks/useTimesStopsTableData.ts @@ -18,7 +18,12 @@ import { interpolateValue } from 'modules/simulationResult/helpers/utils'; import type { SimulationSummary } from 'modules/trainSchedule/types'; import type { Train } from 'reducers/osrdconf/types'; import { getDisplayOnlyPathSteps } from 'reducers/simulationResults/selectors'; -import { Duration } from 'utils/duration'; +import { + Duration, + type StartTime, + addDurationToStartTime, + subtractStartTime, +} from 'utils/duration'; import { ARRIVAL_TIME_ACCEPTABLE_ERROR, marginsUndefined } from '../consts'; import { computeMargins, getTheoreticalMargins } from '../helpers/computeMargins'; @@ -50,7 +55,7 @@ type BuildTableRowParams = { secondaryCode?: string | null; trackName?: string; hasRequestedTrack?: boolean; - startDate: Date; + startDate: StartTime; schedule?: ScheduleItem; computedArrival?: Duration; invalidPathStep?: boolean; @@ -84,17 +89,17 @@ const buildTableRow = ({ margins, }: BuildTableRowParams): TimesStopsRowNew => { const requestedArrival = schedule?.arrival - ? new Date(startDate.getTime() + Duration.parse(schedule.arrival).ms) + ? addDurationToStartTime(startDate, Duration.parse(schedule.arrival)) : null; // computedArrival is offset from startDate const rawComputedArrivalDate = - computedArrival !== undefined ? new Date(startDate.getTime() + computedArrival.ms) : null; + computedArrival !== undefined ? addDurationToStartTime(startDate, computedArrival) : null; // Snap to requested arrival when within tolerance. const isOnTime = requestedArrival && rawComputedArrivalDate - ? Duration.subtractDate(requestedArrival, rawComputedArrivalDate).abs() <= + ? subtractStartTime(requestedArrival, rawComputedArrivalDate).abs() <= ARRIVAL_TIME_ACCEPTABLE_ERROR : false; const computedArrivalDate = isOnTime ? requestedArrival : rawComputedArrivalDate; @@ -105,13 +110,13 @@ const buildTableRow = ({ // requestedDeparture = requestedArrival + stopDuration const requestedDeparture = requestedArrival && stopDuration !== null - ? new Date(requestedArrival.getTime() + stopDuration.ms) + ? addDurationToStartTime(requestedArrival, stopDuration) : null; // computedDeparture = computedArrival + stopDuration const computedDeparture = computedArrivalDate && stopDuration !== null - ? new Date(computedArrivalDate.getTime() + stopDuration.ms) + ? addDurationToStartTime(computedArrivalDate, stopDuration) : null; const { @@ -174,7 +179,7 @@ const useTimesStopsTableData = ( operationalPointsOnPath?: PathPropertiesFormatted['operationalPoints'] ): { allRows: TimesStopsRowNew[]; rows: TimesStopsRowNew[]; stableIsValid: boolean } => { const { t } = useTranslation('operational-studies'); - const { getTrackSectionsByIds } = useScenarioContext(); + const { scenario, getTrackSectionsByIds } = useScenarioContext(); const displayOnlyPathSteps = useSelector(getDisplayOnlyPathSteps); // Stale-while-revalidate: keep the last known-good simulation props in a ref so the table @@ -241,7 +246,10 @@ const useTimesStopsTableData = ( }, [trackIds]); const allRows = useMemo(() => { - const startDate = new Date(selectedTrain.start_time); + const startDate = + scenario.timetable_type === 'CALENDAR' + ? new Date(selectedTrain.start_time) + : new Duration({ milliseconds: selectedTrain.start_time }); const scheduleByAt = keyBy(selectedTrain.schedule, 'at'); const pathIdToIndex = new Map(selectedTrain.path.map((step, idx) => [step.id, idx])); @@ -410,6 +418,7 @@ const useTimesStopsTableData = ( return formattedRows; }, [ + scenario.timetable_type, selectedTrain, stableIsValid, stableTrain, diff --git a/front/src/modules/timesStops/hooks/useUpdateTimesStopsTable.ts b/front/src/modules/timesStops/hooks/useUpdateTimesStopsTable.ts index 01d4ba583cc..797bdedbc11 100644 --- a/front/src/modules/timesStops/hooks/useUpdateTimesStopsTable.ts +++ b/front/src/modules/timesStops/hooks/useUpdateTimesStopsTable.ts @@ -28,7 +28,7 @@ import type { TrainScheduleWithDetails } from 'modules/trainSchedule/types'; import type { OccurrenceId, TrainScheduleId, Train } from 'reducers/osrdconf/types'; import { useAppDispatch } from 'store'; import { removeElementAtIndex, replaceElementAtIndex } from 'utils/array'; -import { Duration } from 'utils/duration'; +import { Duration, type StartTime, startTimeToMs } from 'utils/duration'; import { extractEditoastIdFromTrainScheduleId, extractTrainScheduleIdFromOccurrenceId, @@ -86,7 +86,7 @@ const useUpdateTimesStopsTable = ( trainSchedulesWithDetails: TrainScheduleWithDetails[] ) => { const dispatch = useAppDispatch(); - const { timetableId } = useScenarioContext(); + const { timetableId, scenario } = useScenarioContext(); const { upsertTrainSchedules } = useTimetableContext(); const [updateTrainSchedule] = osrdEditoastApi.endpoints.putTrainSchedulesById.useMutation(); @@ -131,13 +131,13 @@ const useUpdateTimesStopsTable = ( updatedPath: PathItem[]; updatedSchedule: ScheduleItem[]; updatedMargins: TrainSchedule['margins']; - updatedStartTime?: Date; + updatedStartTime?: StartTime; } | undefined => { const propagatedResult = update.field === 'stopDuration' - ? propagateStopDuration(update, selectedTrain) - : propagateTime(update, selectedTrain); + ? propagateStopDuration(update, selectedTrain, scenario.timetable_type) + : propagateTime(update, selectedTrain, scenario.timetable_type); if (propagatedResult) return { ...propagatedResult, updatedMargins: selectedTrain.margins }; const { pathStepId, updatedPath } = upsertPathStep(update.row, selectedTrain.path, allRows); @@ -184,7 +184,10 @@ const useUpdateTimesStopsTable = ( edit ); - const startTime = new Date(selectedTrain.start_time); + const startTime = + scenario.timetable_type === 'CALENDAR' + ? new Date(selectedTrain.start_time) + : new Duration({ milliseconds: selectedTrain.start_time }); const { arrival: newArrival, stop_for: newStopFor } = scheduleStateToApiFields( newState, startTime @@ -218,6 +221,7 @@ const useUpdateTimesStopsTable = ( (update.field === 'requestedArrival' || update.field === 'requestedDeparture') && update.propagationMode === 'atThisWaypoint' && update.value !== null && + update.value instanceof Date && !isOrigin ) { updatedSchedule = adjustFollowingWaypointsForMidnight(update.value, pathStepId, { @@ -231,7 +235,8 @@ const useUpdateTimesStopsTable = ( if ( update.field === 'stopDuration' && update.propagationMode === 'atThisWaypoint' && - newState.departure !== null + newState.departure !== null && + newState.departure instanceof Date ) { updatedSchedule = adjustFollowingWaypointsForMidnight(newState.departure, pathStepId, { ...selectedTrain, @@ -241,7 +246,7 @@ const useUpdateTimesStopsTable = ( return { updatedPath, updatedSchedule, updatedMargins: selectedTrain.margins }; }, - [selectedTrain, allRows, computeUpdatedMargins] + [selectedTrain, allRows, computeUpdatedMargins, scenario.timetable_type] ); /** @@ -326,7 +331,9 @@ const useUpdateTimesStopsTable = ( updatedSchedule: result.updatedSchedule, trainName: occurrenceTrainName, }), - start_time: result.updatedStartTime?.getTime() ?? selectedTrain.start_time, + start_time: result.updatedStartTime + ? startTimeToMs(result.updatedStartTime) + : selectedTrain.start_time, }; } @@ -384,7 +391,9 @@ const useUpdateTimesStopsTable = ( path: result.updatedPath, schedule: result.updatedSchedule, margins: result.updatedMargins, - start_time: result.updatedStartTime?.getTime() ?? selectedTrain.start_time, + start_time: result.updatedStartTime + ? startTimeToMs(result.updatedStartTime) + : selectedTrain.start_time, }); }, [selectedTrain, computeUpdatedPathAndSchedule, updateTrainSchedule] @@ -411,7 +420,7 @@ const useUpdateTimesStopsTable = ( // Functions are included in deps (exception to the project convention) to propagate // allRows updates through the entire callback chain. const updateArrival = useCallback( - (row: TimesStopsRowNew, arrival: Date | null, propagationMode: PropagationMode) => + (row: TimesStopsRowNew, arrival: StartTime | null, propagationMode: PropagationMode) => updateCell({ row, field: 'requestedArrival', @@ -428,7 +437,7 @@ const useUpdateTimesStopsTable = ( ); const updateDeparture = useCallback( - (row: TimesStopsRowNew, departure: Date | null, propagationMode: PropagationMode) => + (row: TimesStopsRowNew, departure: StartTime | null, propagationMode: PropagationMode) => updateCell({ row, field: 'requestedDeparture', diff --git a/front/src/modules/timesStops/types.ts b/front/src/modules/timesStops/types.ts index 9fd07f0b0e5..e5a264b1600 100644 --- a/front/src/modules/timesStops/types.ts +++ b/front/src/modules/timesStops/types.ts @@ -1,7 +1,7 @@ import type { PathItemLocation, ReceptionSignal } from 'common/api/osrdEditoastApi'; import type { TimeString } from 'common/types'; import type { SuggestedOP } from 'modules/trainSchedule/types'; -import type { Duration } from 'utils/duration'; +import type { Duration, StartTime } from 'utils/duration'; import type { MarginUnit } from './consts'; @@ -49,11 +49,11 @@ export type TimesStopsRowNew = { location: PathItemLocation; // Times - requestedArrival: Date | null; - computedArrival: Date | null; + requestedArrival: StartTime | null; + computedArrival: StartTime | null; stopDuration: Duration | null; - requestedDeparture: Date | null; - computedDeparture: Date | null; + requestedDeparture: StartTime | null; + computedDeparture: StartTime | null; // Signaling options closedSignal?: boolean; @@ -127,7 +127,7 @@ export type UpdateCellStatus = 'updated' | 'skipped'; export type ArrivalUpdate = { row: TimesStopsRowNew; field: 'requestedArrival'; - value: Date | null; + value: StartTime | null; propagationMode: PropagationMode; }; @@ -141,7 +141,7 @@ export type StopDurationUpdate = { export type DepartureUpdate = { row: TimesStopsRowNew; field: 'requestedDeparture'; - value: Date | null; + value: StartTime | null; propagationMode: PropagationMode; }; @@ -172,10 +172,10 @@ export type CellUpdate = | PowerRestrictionUpdate; export type OptimisticEdit = - | { field: 'requestedArrival'; value: Date | null } - | { field: 'requestedDeparture'; value: Date | null } + | { field: 'requestedArrival'; value: StartTime | null } + | { field: 'requestedDeparture'; value: StartTime | null } | { field: 'stopDuration'; value: Duration | null } - | { field: 'stopDurationWithArrival'; value: { stop: Duration | null; arrival: Date } } + | { field: 'stopDurationWithArrival'; value: { stop: Duration | null; arrival: StartTime } } | { field: 'receptionSignal'; value: ReceptionSignal | undefined } | { field: 'requestedTheoreticalMargin'; value: MarginValue | null } | { field: 'powerRestriction'; value: string | null };