diff --git a/front/public/locales/en/timesStops.json b/front/public/locales/en/timesStops.json index 8071ee03bb0..7c21612df65 100644 --- a/front/public/locales/en/timesStops.json +++ b/front/public/locales/en/timesStops.json @@ -2,6 +2,7 @@ "arrivalTime": "Requested arrival Time", "calculatedArrivalTime": "Calculated arrival time", "calculatedDepartureTime": "Calculated departure time", + "dayCounter": "D+{{count}}", "departureTime": "Requested departure Time", "diffMargins": "Margins diff.", "name": "Name", diff --git a/front/public/locales/fr/timesStops.json b/front/public/locales/fr/timesStops.json index cada441413f..bb09abff5c8 100644 --- a/front/public/locales/fr/timesStops.json +++ b/front/public/locales/fr/timesStops.json @@ -2,6 +2,7 @@ "arrivalTime": "Arrivée demandée", "calculatedArrivalTime": "Arrivée calculée", "calculatedDepartureTime": "Départ calculé", + "dayCounter": "J+{{count}}", "departureTime": "Départ demandé", "diffMargins": "Diff. marges", "name": "Nom", diff --git a/front/src/applications/operationalStudies/hooks/useSetupItineraryForTrainUpdate.ts b/front/src/applications/operationalStudies/hooks/useSetupItineraryForTrainUpdate.ts index 9c9f682cb78..8cc1d3150bf 100644 --- a/front/src/applications/operationalStudies/hooks/useSetupItineraryForTrainUpdate.ts +++ b/front/src/applications/operationalStudies/hooks/useSetupItineraryForTrainUpdate.ts @@ -22,7 +22,6 @@ import { setFailure } from 'reducers/main'; import type { OperationalStudiesConfSliceActions } from 'reducers/osrdconf/operationalStudiesConf'; import type { PathStep } from 'reducers/osrdconf/types'; import { useAppDispatch } from 'store'; -import { addDurationToIsoDate } from 'utils/date'; import { castErrorToFailure } from 'utils/error'; import { getPointCoordinates } from 'utils/geometry'; import { mmToM } from 'utils/physics'; @@ -70,9 +69,7 @@ const computeBasePathSteps = (trainSchedule: TrainScheduleResult) => ...stepWithoutSecondaryCode, ch: 'secondary_code' in step ? step.secondary_code : undefined, name, - arrival: arrival - ? addDurationToIsoDate(trainSchedule.start_time, arrival).substring(11, 19) - : arrival, + arrival, // ISODurationString stopFor: stopFor ? ISO8601Duration2sec(stopFor).toString() : stopFor, locked, onStopSignal, diff --git a/front/src/common/types.ts b/front/src/common/types.ts index 371c2aadae8..324eb16c683 100644 --- a/front/src/common/types.ts +++ b/front/src/common/types.ts @@ -15,6 +15,21 @@ export const DATA_TYPES = { */ export type TimeString = string; +/** + * A string with the complete iso format + * + * @example "2024-08-08T10:12:46.209Z" + * @example "2024-08-08T10:12:46Z" + * @example "2024-08-08T10:12:46+02:00" + */ +export type IsoDateTimeString = string; + +/** + * A ISO 8601 duration string + * @example "PT3600S" + */ +export type IsoDurationString = string; + export type RangedValue = { begin: number; end: number; diff --git a/front/src/modules/pathfinding/utils.ts b/front/src/modules/pathfinding/utils.ts index e33e2b27b49..d61142b944a 100644 --- a/front/src/modules/pathfinding/utils.ts +++ b/front/src/modules/pathfinding/utils.ts @@ -157,6 +157,18 @@ export const upsertPathStepsInOPs = (ops: SuggestedOP[], pathSteps: PathStep[]): return updatedOPs; }; +export const pathStepMatchesOp = ( + pathStep: PathStep, + op: Pick, + withKP = false +) => + ('uic' in pathStep && + 'ch' in pathStep && + pathStep.uic === op.uic && + pathStep.ch === op.ch && + (withKP ? pathStep.kp === op.kp : pathStep.name === op.name)) || + pathStep.id === op.opId; + /** * Check if a suggested operational point is a via. * Some OPs have same uic so we need to check also the ch (can be still not enough @@ -165,13 +177,8 @@ export const upsertPathStepsInOPs = (ops: SuggestedOP[], pathSteps: PathStep[]): * @param withKP - If true, we check the kp compatibility instead of the name. * It is used in the times and stops table to check if an operational point is a via. */ -export const isVia = (vias: PathStep[], op: SuggestedOP, withKP = false) => - vias.some( - (via) => - ('uic' in via && - 'ch' in via && - via.uic === op.uic && - via.ch === op.ch && - (withKP ? via.kp === op.kp : via.name === op.name)) || - via.id === op.opId - ); +export const isVia = ( + vias: PathStep[], + op: Pick, + { withKP = false } = {} +) => vias.some((via) => pathStepMatchesOp(via, op, withKP)); diff --git a/front/src/modules/timesStops/ReadOnlyTime.tsx b/front/src/modules/timesStops/ReadOnlyTime.tsx new file mode 100644 index 00000000000..2c47adda927 --- /dev/null +++ b/front/src/modules/timesStops/ReadOnlyTime.tsx @@ -0,0 +1,23 @@ +import type { CellProps } from 'react-datasheet-grid/dist/types'; +import { useTranslation } from 'react-i18next'; + +import { NO_BREAK_SPACE } from 'utils/strings'; + +import type { TimeExtraDays } from './types'; + +type ReadOnlyTimeProps = CellProps; + +const ReadOnlyTime = ({ rowData }: ReadOnlyTimeProps) => { + const { time, daySinceDeparture, dayDisplayed } = rowData || {}; + if (!time) { + return null; + } + const { t } = useTranslation('timesStops'); + const fullString = + daySinceDeparture !== undefined && dayDisplayed + ? `${time}${NO_BREAK_SPACE}${t('dayCounter', { count: daySinceDeparture })}` + : time; + return
{fullString}
; +}; + +export default ReadOnlyTime; diff --git a/front/src/modules/timesStops/TimeInput.tsx b/front/src/modules/timesStops/TimeInput.tsx index 7a2d879d0fe..63406680695 100644 --- a/front/src/modules/timesStops/TimeInput.tsx +++ b/front/src/modules/timesStops/TimeInput.tsx @@ -1,15 +1,17 @@ import { useRef, useState, useEffect } from 'react'; +import cx from 'classnames'; import type { CellProps } from 'react-datasheet-grid/dist/types'; +import { useTranslation } from 'react-i18next'; -const TimeInput = ({ - focus, - rowData, - active, - setRowData, -}: CellProps) => { +import type { TimeExtraDays } from './types'; + +type TimeInputProps = CellProps; + +const TimeInput = ({ focus, rowData, active, setRowData }: TimeInputProps) => { + const { t } = useTranslation('timesStops'); const ref = useRef(null); - const [tempTimeValue, setTempTimeValue] = useState(rowData); + const [tempTimeValue, setTempTimeValue] = useState(rowData); useEffect(() => { if (active) { @@ -26,8 +28,9 @@ const TimeInput = ({ setTempTimeValue(rowData); }, [rowData]); - return ( + const input = ( { - setTempTimeValue(e.target.value); + setTempTimeValue((prev) => ({ ...prev, time: e.target.value })); }} onBlur={() => { // To prevent the operational point to be transformed into a via if we leave the cell empty after focusing it @@ -49,8 +52,22 @@ const TimeInput = ({ }} /> ); -}; -TimeInput.displayName = 'TimeInput'; + if (tempTimeValue?.daySinceDeparture && tempTimeValue.dayDisplayed) { + return ( +
+ {input} + + {t('dayCounter', { count: tempTimeValue.daySinceDeparture })} + +
+ ); + } + return input; +}; export default TimeInput; diff --git a/front/src/modules/timesStops/TimesStops.tsx b/front/src/modules/timesStops/TimesStops.tsx index 389c0eec440..eb33d19210c 100644 --- a/front/src/modules/timesStops/TimesStops.tsx +++ b/front/src/modules/timesStops/TimesStops.tsx @@ -1,18 +1,23 @@ import { useState, useEffect } from 'react'; import cx from 'classnames'; +import { isEqual } from 'lodash'; import { DynamicDataSheetGrid, type DataSheetGridProps } from 'react-datasheet-grid'; import { useTranslation } from 'react-i18next'; import { useOsrdConfActions } from 'common/osrdContext'; +import type { IsoDateTimeString } from 'common/types'; import { isVia } from 'modules/pathfinding/utils'; import type { SuggestedOP } from 'modules/trainschedule/components/ManageTrainSchedule/types'; import type { PathStep } from 'reducers/osrdconf/types'; import { useAppDispatch } from 'store'; -import { time2sec } from 'utils/timeManipulation'; -import { marginRegExValidation } from './consts'; -import { formatSuggestedViasToRowVias } from './helpers/utils'; +import { + formatSuggestedViasToRowVias, + updateRowTimesAndMargin, + updateDaySinceDeparture, + durationSinceStartTime, +} from './helpers/utils'; import { useTimeStopsColumns } from './hooks/useTimeStopsColumns'; import { TableType } from './types'; import type { PathWaypointRow } from './types'; @@ -22,7 +27,7 @@ export const WITH_KP = true; type TimesStopsProps = { allWaypoints?: SuggestedOP[]; pathSteps?: PathStep[]; - startTime?: string; + startTime?: IsoDateTimeString; tableType: TableType; cellClassName?: DataSheetGridProps['cellClassName']; stickyRightColumn?: DataSheetGridProps['stickyRightColumn']; @@ -42,7 +47,7 @@ const TimesStops = ({ const { t } = useTranslation('timesStops'); const dispatch = useAppDispatch(); - const { upsertViaFromSuggestedOP } = useOsrdConfActions(); + const { upsertSeveralViasFromSuggestedOP } = useOsrdConfActions(); const [rows, setRows] = useState([]); @@ -55,7 +60,7 @@ const TimesStops = ({ startTime, tableType ); - setRows(suggestedOPs); + setRows(updateDaySinceDeparture(suggestedOPs, startTime, true)); } }, [allWaypoints, pathSteps, startTime]); @@ -74,35 +79,38 @@ const TimesStops = ({ className="time-stops-datasheet" columns={columns} value={rows} - onChange={(row: PathWaypointRow[], [op]) => { + onChange={(newRows: PathWaypointRow[], [op]) => { if (!isInputTable) { return; } - const rowData = { ...row[op.fromRowIndex] }; - const previousRowData = rows[op.fromRowIndex]; - if ( - rowData.departure && - rowData.arrival && - (rowData.arrival !== previousRowData.arrival || - rowData.departure !== previousRowData.departure) + let updatedRows = [...newRows]; + updatedRows[op.fromRowIndex] = updateRowTimesAndMargin( + newRows[op.fromRowIndex], + rows[op.fromRowIndex], + op, + allWaypoints.length + ); + updatedRows = updateDaySinceDeparture(updatedRows, startTime); + if (!updatedRows[op.fromRowIndex].isMarginValid) { + newRows[op.fromRowIndex].isMarginValid = false; + setRows(newRows); + } else if ( + !rows[op.fromRowIndex].isMarginValid && + updatedRows[op.fromRowIndex].isMarginValid ) { - rowData.stopFor = String(time2sec(rowData.departure) - time2sec(rowData.arrival)); - } - if (!rowData.stopFor && op.fromRowIndex !== allWaypoints.length - 1) { - rowData.onStopSignal = false; - } - if (rowData.theoreticalMargin && !marginRegExValidation.test(rowData.theoreticalMargin)) { - rowData.isMarginValid = false; - setRows(row); + newRows[op.fromRowIndex].isMarginValid = true; + setRows(newRows); } else { - rowData.isMarginValid = true; - if (op.fromRowIndex === 0) { - rowData.arrival = null; - // As we put 0% by default for origin's margin, if the user removes a margin without - // replacing it to 0% (undefined), we change it to 0% - if (!rowData.theoreticalMargin) rowData.theoreticalMargin = '0%'; - } - dispatch(upsertViaFromSuggestedOP(rowData as SuggestedOP)); + const newVias = updatedRows + .filter((row, index) => !isEqual(row, rows[index])) + .map( + (row) => + ({ + ...row, + ...(row.arrival && { arrival: durationSinceStartTime(startTime, row.arrival) }), + }) as SuggestedOP + ); + dispatch(upsertSeveralViasFromSuggestedOP(newVias)); } }} stickyRightColumn={stickyRightColumn} @@ -114,7 +122,7 @@ const TimesStops = ({ activeRow: rowIndex === 0 || rowIndex === allWaypoints.length - 1 || - isVia(pathSteps || [], rowData, WITH_KP), + isVia(pathSteps || [], rowData, { withKP: true }), }) } cellClassName={cellClassName} diff --git a/front/src/modules/timesStops/TimesStopsInput.tsx b/front/src/modules/timesStops/TimesStopsInput.tsx index b789143219a..4549ad68cf4 100644 --- a/front/src/modules/timesStops/TimesStopsInput.tsx +++ b/front/src/modules/timesStops/TimesStopsInput.tsx @@ -1,6 +1,7 @@ /* eslint-disable react/jsx-no-useless-fragment */ import { useOsrdConfActions } from 'common/osrdContext'; +import { isVia } from 'modules/pathfinding/utils'; import type { SuggestedOP } from 'modules/trainschedule/components/ManageTrainSchedule/types'; import type { PathStep } from 'reducers/osrdconf/types'; import { useAppDispatch } from 'store'; @@ -16,11 +17,18 @@ type ClearButtonProps = { pathSteps: PathStep[]; }; -const createClearViaButton = ({ removeVia, rowIndex, rowData, allWaypoints }: ClearButtonProps) => { +const createClearViaButton = ({ + removeVia, + rowIndex, + rowData, + allWaypoints, + pathSteps, +}: ClearButtonProps) => { const isClearBtnShown = allWaypoints && rowIndex > 0 && rowIndex < allWaypoints.length - 1 && + isVia(pathSteps || [], rowData, { withKP: true }) && (rowData.stopFor !== undefined || rowData.theoreticalMargin !== undefined || rowData.arrival !== undefined || @@ -79,7 +87,6 @@ const TimesStopsinput = ({ allWaypoints, startTime, pathSteps }: TimesStopsInput }); dispatch(updatePathSteps({ pathSteps: updatedPathSteps })); }; - return ( { const rowData = rowData_ as PathWaypointRow; - const arrivalScheduleNotRespected = rowData.arrival - ? rowData.calculatedArrival !== rowData.arrival + const arrivalScheduleNotRespected = rowData.arrival?.time + ? rowData.calculatedArrival !== rowData.arrival.time : false; const negativeDiffMargins = Number(rowData.diffMargins?.split(NO_BREAK_SPACE)[0]) < 0; return cx({ diff --git a/front/src/modules/timesStops/helpers/__tests__/scheduleData.spec.ts b/front/src/modules/timesStops/helpers/__tests__/scheduleData.spec.ts index b208a18e86d..494cd060f62 100644 --- a/front/src/modules/timesStops/helpers/__tests__/scheduleData.spec.ts +++ b/front/src/modules/timesStops/helpers/__tests__/scheduleData.spec.ts @@ -3,20 +3,21 @@ import { describe, it, expect } from 'vitest'; import { computeScheduleData } from '../scheduleData'; describe('computeScheduleData', () => { - it('should compute simple arrival time in the correct timezone', () => { - const schedule = { - at: 'id325', - arrival: 'PT3600S', - stop_for: 'PT100S', - on_stop_signal: false, - locked: false, - }; - const startTime = '2024-05-14T00:00:00Z'; + describe('same day', () => { + it('should compute simple arrival time in the correct timezone', () => { + const schedule = { + at: 'id325', + arrival: 'PT3600S', + stop_for: 'PT100S', + on_stop_signal: false, + locked: false, + }; - expect(computeScheduleData(schedule, startTime)).toEqual({ - arrival: 3600, - departure: 3700, - stopFor: 100, + expect(computeScheduleData(schedule)).toEqual({ + arrival: 3600, + departure: 3700, + stopFor: 100, + }); }); }); }); diff --git a/front/src/modules/timesStops/helpers/__tests__/utils.spec.ts b/front/src/modules/timesStops/helpers/__tests__/utils.spec.ts new file mode 100644 index 00000000000..7bf4b902b75 --- /dev/null +++ b/front/src/modules/timesStops/helpers/__tests__/utils.spec.ts @@ -0,0 +1,610 @@ +import { describe, it, expect } from 'vitest'; + +import { type PathWaypointRow } from 'modules/timesStops/types'; + +import { + updateRowTimesAndMargin, + updateDaySinceDeparture, + durationSinceStartTime, + calculateStepTimeAndDays, +} from '../utils'; + +describe('updateRowTimesAndMargin', () => { + const whateverOperation = { fromRowIndex: 2 }; + + describe('arrival is set, departure just changed', () => { + it('should update stop duration from the arrival and departure', () => { + const rowData = { + opId: 'd94a2af4', + name: 'Gr', + arrival: { time: '23:40:00' }, + departure: { time: '23:50:00' }, + stopFor: '300', // no longer correct, not yet updated by the function + } as PathWaypointRow; + const previousRowData = { + opId: 'd94a2af4', + name: 'Gr', + arrival: { time: '23:40:00' }, + departure: { time: '23:45:00' }, + stopFor: '300', + } as PathWaypointRow; + const result = updateRowTimesAndMargin(rowData, previousRowData, whateverOperation, 4); + expect(result).toEqual({ + opId: 'd94a2af4', + name: 'Gr', + arrival: { time: '23:40:00' }, + departure: { time: '23:50:00' }, + stopFor: '600', // now correct with the new arrival and departure + isMarginValid: true, + }); + }); + }); + describe('theoretical margin is incorrect', () => { + it('should set isMarginValid flag to false', () => { + const rowData = { + opId: 'd94a2af4', + name: 'Gr', + theoreticalMargin: '10', + } as PathWaypointRow; + const previousRowData = { + opId: 'd94a2af4', + name: 'Gr', + } as PathWaypointRow; + const result = updateRowTimesAndMargin(rowData, previousRowData, whateverOperation, 4); + expect(result.isMarginValid).toBe(false); + }); + }); + describe('user removed first row theoretical margin', () => { + it('should set the theoretical margin back to 0%', () => { + const rowData = { + opId: 'd94a2af4', + name: 'Gr', + } as PathWaypointRow; + const previousRowData = { + opId: 'd94a2af4', + name: 'Gr', + theoreticalMargin: '10%', + } as PathWaypointRow; + const operation = { + fromRowIndex: 0, + }; + const result = updateRowTimesAndMargin(rowData, previousRowData, operation, 4); + expect(result).toEqual({ + opId: 'd94a2af4', + name: 'Gr', + arrival: undefined, + isMarginValid: true, + onStopSignal: false, + theoreticalMargin: '0%', + }); + }); + }); + describe('arrival is before midnight, departure after midnight', () => { + it('should compute the stopFor correctly', () => { + const rowData = { + opId: 'd94a2af4', + name: 'Gr', + arrival: { time: '23:40:00' }, + departure: { time: '00:20:00' }, + stopFor: '300', + } as PathWaypointRow; + const previousRowData = { + opId: 'd94a2af4', + name: 'Gr', + arrival: { time: '23:40:00' }, + departure: { time: '23:45:00' }, + stopFor: '300', + } as PathWaypointRow; + const result = updateRowTimesAndMargin(rowData, previousRowData, whateverOperation, 4); + expect(result).toEqual({ + opId: 'd94a2af4', + name: 'Gr', + arrival: { time: '23:40:00' }, + departure: { time: '00:20:00' }, + stopFor: '2400', + isMarginValid: true, + }); + }); + }); + describe('arrival, departure & stopFor are set, arrival gets erased', () => { + it('should keep stopFor and remove departure', () => { + const rowData = { + opId: 'd94a2af4', + name: 'Gr', + arrival: undefined, + departure: { time: '00:20:00' }, + stopFor: '600', + } as PathWaypointRow; + const previousRowData = { + opId: 'd94a2af4', + name: 'Gr', + arrival: { time: '00:10:00' }, + departure: { time: '00:20:00' }, + stopFor: '600', + } as PathWaypointRow; + const result = updateRowTimesAndMargin(rowData, previousRowData, whateverOperation, 4); + expect(result).toEqual({ + opId: 'd94a2af4', + name: 'Gr', + arrival: undefined, + departure: undefined, + stopFor: '600', + isMarginValid: true, + }); + }); + it('should keep stopFor and remove departure (double click + delete button version', () => { + const rowData = { + opId: 'd94a2af4', + name: 'Gr', + arrival: { time: '' }, + departure: { time: '00:20:00' }, + stopFor: '600', + } as PathWaypointRow; + const previousRowData = { + opId: 'd94a2af4', + name: 'Gr', + arrival: { time: '00:10:00' }, + departure: { time: '00:20:00' }, + stopFor: '600', + } as PathWaypointRow; + const result = updateRowTimesAndMargin(rowData, previousRowData, whateverOperation, 4); + expect(result).toEqual({ + opId: 'd94a2af4', + name: 'Gr', + arrival: { time: '' }, + departure: undefined, + stopFor: '600', + isMarginValid: true, + }); + }); + }); + describe('arrival, departure & stopFor are set, departure gets erased', () => { + it('should keep arrival and remove stopFor', () => { + const rowData = { + opId: 'd94a2af4', + name: 'Gr', + arrival: { time: '00:10:00' }, + departure: undefined, + stopFor: '600', + } as PathWaypointRow; + const previousRowData = { + opId: 'd94a2af4', + name: 'Gr', + arrival: { time: '00:10:00' }, + departure: { time: '00:20:00' }, + stopFor: '600', + } as PathWaypointRow; + const result = updateRowTimesAndMargin(rowData, previousRowData, whateverOperation, 4); + expect(result).toEqual({ + opId: 'd94a2af4', + name: 'Gr', + arrival: { time: '00:10:00' }, + departure: undefined, + stopFor: undefined, + isMarginValid: true, + onStopSignal: false, + }); + }); + }); + describe('stopFor only is set, departure gets added', () => { + it('should set arrival too', () => { + const rowData = { + opId: 'd94a2af4', + name: 'Gr', + arrival: undefined, + departure: { time: '00:20:00' }, + stopFor: '600', + } as PathWaypointRow; + const previousRowData = { + opId: 'd94a2af4', + name: 'Gr', + arrival: undefined, + departure: undefined, + stopFor: '600', + } as PathWaypointRow; + const result = updateRowTimesAndMargin(rowData, previousRowData, whateverOperation, 4); + expect(result).toEqual({ + opId: 'd94a2af4', + name: 'Gr', + arrival: { time: '00:10:00' }, + departure: { time: '00:20:00' }, + stopFor: '600', + isMarginValid: true, + }); + }); + }); +}); + +describe('updateDaySinceDeparture', () => { + describe('1 day span', () => { + it('should add the day since departure', () => { + const pathWaypointRows = [ + { + opId: 'd9c92cb4', + name: 'Ge', + uic: 86, + ch: 'BV', + arrival: { time: '10:00:00' }, + }, + ] as PathWaypointRow[]; + const startTime = '2024-08-13T10:00:00'; + const result = updateDaySinceDeparture(pathWaypointRows, startTime, true); + const expected = [ + { + opId: 'd9c92cb4', + name: 'Ge', + uic: 86, + ch: 'BV', + arrival: { time: '10:00:00', daySinceDeparture: 0 }, + departure: undefined, + }, + ]; + expect(result).toEqual(expected); + }); + it('should format departure', () => { + const pathWaypointRows = [ + { + opId: 'd9c92cb4', + name: 'Ge', + uic: 86, + ch: 'BV', + arrival: { time: '10:00:00' }, + }, + { + opId: 'd9c92cb4', + name: 'Ge', + uic: 86, + ch: 'BX', + arrival: { time: '11:00:00' }, + stopFor: '1800', + }, + ] as PathWaypointRow[]; + const startTime = '2024-08-13T10:00:00'; + const result = updateDaySinceDeparture(pathWaypointRows, startTime, true); + const expected = [ + { + opId: 'd9c92cb4', + name: 'Ge', + uic: 86, + ch: 'BV', + arrival: { time: '10:00:00', daySinceDeparture: 0 }, + departure: undefined, + }, + { + opId: 'd9c92cb4', + name: 'Ge', + uic: 86, + ch: 'BX', + arrival: { time: '11:00:00', daySinceDeparture: 0 }, + departure: { time: '11:30:00', daySinceDeparture: 0 }, + stopFor: '1800', + }, + ]; + expect(result).toEqual(expected); + }); + }); + describe('2 day span', () => { + it('should add day 1 field', () => { + const pathWaypointRows = [ + { + opId: 'd9c92cb4', + name: 'Ge', + uic: 86, + ch: 'BV', + arrival: { time: '23:50:00' }, + }, + { + opId: 'd9b38600', + name: 'Ge', + uic: 86, + ch: 'BX', + arrival: { time: '00:30:00' }, + }, + ] as PathWaypointRow[]; + const startTime = '2024-08-13T23:50:00'; + const result = updateDaySinceDeparture(pathWaypointRows, startTime, true); + const expected = [ + { + opId: 'd9c92cb4', + name: 'Ge', + uic: 86, + ch: 'BV', + arrival: { time: '23:50:00', daySinceDeparture: 0 }, + departure: undefined, + }, + { + opId: 'd9b38600', + name: 'Ge', + uic: 86, + ch: 'BX', + arrival: { time: '00:30:00', daySinceDeparture: 1, dayDisplayed: true }, + departure: undefined, + }, + ]; + expect(result).toEqual(expected); + }); + it('should add display flag for the first time in the new day', () => { + const pathWaypointRows = [ + { + opId: 'd9c92cb4', + name: 'Ge', + uic: 86, + ch: 'BV', + arrival: { time: '23:50:00' }, + }, + { + opId: 'd9b38600', + name: 'Ge', + uic: 84, + ch: 'BX', + arrival: { time: '00:30:00' }, + }, + { + opId: 'd982df3e', + name: 'St', + uic: 82, + ch: 'BV', + arrival: undefined, + }, + { + opId: 'd982df3e', + name: 'Vp', + uic: 78, + ch: 'BV', + arrival: { time: '00:50:00' }, + }, + ] as PathWaypointRow[]; + const startTime = '2024-08-13T23:50:00'; + const result = updateDaySinceDeparture(pathWaypointRows, startTime, true); + const expected = [ + { + opId: 'd9c92cb4', + name: 'Ge', + uic: 86, + ch: 'BV', + arrival: { time: '23:50:00', daySinceDeparture: 0 }, + departure: undefined, + }, + { + opId: 'd9b38600', + name: 'Ge', + uic: 84, + ch: 'BX', + arrival: { time: '00:30:00', daySinceDeparture: 1, dayDisplayed: true }, + departure: undefined, + }, + { + opId: 'd982df3e', + name: 'St', + uic: 82, + ch: 'BV', + arrival: undefined, + departure: undefined, + }, + { + opId: 'd982df3e', + name: 'Vp', + uic: 78, + ch: 'BV', + arrival: { time: '00:50:00', daySinceDeparture: 1 }, + departure: undefined, + }, + ]; + expect(result).toEqual(expected); + }); + it('should handle stop on d+1', () => { + const pathWaypointRows = [ + { + opId: 'd9c92cb4', + name: 'Ge', + uic: 86, + ch: 'BV', + arrival: { time: '23:50:00' }, + }, + { + opId: 'd9b38600', + name: 'Ge', + uic: 84, + ch: 'BX', + arrival: { time: '23:55:00' }, + stopFor: '3600', + }, + { + opId: 'd982df3e', + name: 'St', + uic: 82, + ch: 'BV', + arrival: { time: '00:56:00' }, + }, + ] as PathWaypointRow[]; + const startTime = '2024-08-13T23:50:00'; + const result = updateDaySinceDeparture(pathWaypointRows, startTime, true); + const expected = [ + { + opId: 'd9c92cb4', + name: 'Ge', + uic: 86, + ch: 'BV', + arrival: { time: '23:50:00', daySinceDeparture: 0 }, + departure: undefined, + }, + { + opId: 'd9b38600', + name: 'Ge', + uic: 84, + ch: 'BX', + arrival: { time: '23:55:00', daySinceDeparture: 0 }, + departure: { time: '00:55:00', daySinceDeparture: 1, dayDisplayed: true }, + stopFor: '3600', + }, + { + opId: 'd982df3e', + name: 'St', + uic: 82, + ch: 'BV', + arrival: { time: '00:56:00', daySinceDeparture: 1 }, + departure: undefined, + }, + ]; + expect(result).toEqual(expected); + }); + }); + describe('3 day span', () => { + it('should add display flag for the first time in the new day', () => { + const pathWaypointRows = [ + { + opId: 'd9c92cb4', + name: 'Ge', + uic: 86, + ch: 'BV', + arrival: { time: '23:50:00' }, + }, + { + opId: 'd9b38600', + name: 'Ge', + uic: 84, + ch: 'BX', + arrival: { time: '00:30:00' }, + }, + { + opId: 'd982df3e', + name: 'St', + uic: 82, + ch: 'BV', + }, + { + opId: 'auie', + name: 'Vp', + uic: 78, + ch: 'BV', + arrival: { time: '00:50:00' }, + }, + { + opId: 'bépo', + name: 'Uj', + uic: 76, + ch: 'BV', + arrival: { time: '18:50:00' }, + }, + { + opId: 'àyx.', + name: 'Vs', + uic: 72, + ch: 'BV', + arrival: { time: '23:30:00' }, + stopFor: '3600', + }, + ] as PathWaypointRow[]; + const startTime = '2024-08-13T23:50:00'; + const result = updateDaySinceDeparture(pathWaypointRows, startTime, true); + const expected = [ + { + opId: 'd9c92cb4', + name: 'Ge', + uic: 86, + ch: 'BV', + arrival: { time: '23:50:00', daySinceDeparture: 0 }, + departure: undefined, + }, + { + opId: 'd9b38600', + name: 'Ge', + uic: 84, + ch: 'BX', + arrival: { time: '00:30:00', daySinceDeparture: 1, dayDisplayed: true }, + departure: undefined, + }, + { + opId: 'd982df3e', + name: 'St', + uic: 82, + ch: 'BV', + arrival: undefined, + departure: undefined, + }, + { + opId: 'auie', + name: 'Vp', + uic: 78, + ch: 'BV', + arrival: { time: '00:50:00', daySinceDeparture: 1 }, + departure: undefined, + }, + { + opId: 'bépo', + name: 'Uj', + uic: 76, + ch: 'BV', + arrival: { time: '18:50:00', daySinceDeparture: 1 }, + departure: undefined, + }, + { + opId: 'àyx.', + name: 'Vs', + uic: 72, + ch: 'BV', + arrival: { time: '23:30:00', daySinceDeparture: 1 }, + departure: { + time: '00:30:00', + daySinceDeparture: 2, + dayDisplayed: true, + }, + stopFor: '3600', + }, + ]; + expect(result).toEqual(expected); + }); + }); +}); + +describe('durationSinceStartTime', () => { + it('should return the correct duration', () => { + const startTime = '2023-09-01T10:00:00Z'; + const stepTimeDays = { + time: '20:00:00', + daySinceDeparture: 0, + }; + + const result = durationSinceStartTime(startTime, stepTimeDays); + + expect(result).toBe('PT36000S'); + }); + + it('should return the correct duration. daySinceDeparture 1', () => { + const startTime = '2023-09-01T10:00:00Z'; + const stepTimeDays = { + time: '11:00:00', + daySinceDeparture: 1, + }; + + const result = durationSinceStartTime(startTime, stepTimeDays); + + expect(result).toBe('PT90000S'); + }); +}); + +describe('calculateStepTimeDays', () => { + it('should return correct time and daySinceDeparture', () => { + const startTime = '2023-09-01T10:00:00Z'; + const isoDuration = 'PT36000S'; // 10 hours + + const result = calculateStepTimeAndDays(startTime, isoDuration); + + expect(result).toEqual({ + time: '20:00:00', + daySinceDeparture: 0, + }); + }); + + it('should return correct time and daySinceDeparture, daySinceDeparture 1', () => { + const startTime = '2023-09-01T10:00:00Z'; + const isoDuration = 'PT122400S'; // 1 day 10 hours + + const result = calculateStepTimeAndDays(startTime, isoDuration); + + expect(result).toEqual({ + time: '20:00:00', + daySinceDeparture: 1, + }); + }); +}); diff --git a/front/src/modules/timesStops/helpers/arrivalTime.ts b/front/src/modules/timesStops/helpers/arrivalTime.ts index 4262aad5708..e15028b2b6f 100644 --- a/front/src/modules/timesStops/helpers/arrivalTime.ts +++ b/front/src/modules/timesStops/helpers/arrivalTime.ts @@ -15,12 +15,12 @@ export function checkAndFormatCalculatedArrival( operationalPointTime: number ) { if (!scheduleData.arrival) { - return secToHoursString(operationalPointTime, true); + return secToHoursString(operationalPointTime, { withSeconds: true }); } const arrivalValuesAreClose = Math.abs(scheduleData.arrival - (operationalPointTime % SECONDS_IN_A_DAY)) <= ARRIVAL_TIME_ACCEPTABLE_ERROR_MS / 1000; const calculatedArrival = arrivalValuesAreClose ? scheduleData.arrival : operationalPointTime; - return secToHoursString(calculatedArrival, true); + return secToHoursString(calculatedArrival, { withSeconds: true }); } diff --git a/front/src/modules/timesStops/helpers/computeMargins.ts b/front/src/modules/timesStops/helpers/computeMargins.ts index bb0b647da4b..b1557f6ddae 100644 --- a/front/src/modules/timesStops/helpers/computeMargins.ts +++ b/front/src/modules/timesStops/helpers/computeMargins.ts @@ -14,18 +14,18 @@ function getTheoreticalMargin(selectedTrainSchedule: TrainScheduleResult, pathSt if (selectedTrainSchedule.path[0].id === pathStepId) { return selectedTrainSchedule.margins?.values[0]; } - const theoriticalMarginBoundaryIndex = selectedTrainSchedule.margins?.boundaries?.findIndex( + const theoreticalMarginBoundaryIndex = selectedTrainSchedule.margins?.boundaries?.findIndex( (id) => id === pathStepId ); if ( - theoriticalMarginBoundaryIndex === undefined || - theoriticalMarginBoundaryIndex < 0 || - theoriticalMarginBoundaryIndex > selectedTrainSchedule.margins!.values.length - 2 + theoreticalMarginBoundaryIndex === undefined || + theoreticalMarginBoundaryIndex < 0 || + theoreticalMarginBoundaryIndex > selectedTrainSchedule.margins!.values.length - 2 ) { return undefined; } - return selectedTrainSchedule.margins!.values[theoriticalMarginBoundaryIndex + 1]; + return selectedTrainSchedule.margins!.values[theoreticalMarginBoundaryIndex + 1]; } function computeDuration( diff --git a/front/src/modules/timesStops/helpers/scheduleData.ts b/front/src/modules/timesStops/helpers/scheduleData.ts index e4d216a26a8..166bee7c41e 100644 --- a/front/src/modules/timesStops/helpers/scheduleData.ts +++ b/front/src/modules/timesStops/helpers/scheduleData.ts @@ -1,37 +1,42 @@ -import { ISO8601Duration2sec, datetime2sec, secToHoursString } from 'utils/timeManipulation'; +import type { IsoDurationString } from 'common/types'; +import type { SuggestedOP } from 'modules/trainschedule/components/ManageTrainSchedule/types'; +import { ISO8601Duration2sec, formatDurationAsISO8601 } from 'utils/timeManipulation'; import type { ComputedScheduleEntry, ScheduleEntry } from '../types'; /** * * @param schedule for a given operational point - * @param startTime time of departure from the very beginning of the route */ -export function computeScheduleData(schedule: ScheduleEntry, startTime: string) { +export function computeScheduleData(schedule: ScheduleEntry) { if (!schedule) { return { arrival: null, departure: null, stopFor: null }; } - const startTimeSeconds = datetime2sec(new Date(startTime)); - // relative value, number of seconds since startTime - const arrivalSeconds = schedule.arrival - ? startTimeSeconds + ISO8601Duration2sec(schedule.arrival) - : null; - + const arrivalSeconds = schedule.arrival ? ISO8601Duration2sec(schedule.arrival) : null; const stopForSeconds = schedule.stop_for ? ISO8601Duration2sec(schedule.stop_for) : null; + const departureSeconds = + arrivalSeconds && stopForSeconds ? arrivalSeconds + stopForSeconds : null; - const departure = - arrivalSeconds && stopForSeconds ? startTimeSeconds + arrivalSeconds + stopForSeconds : null; return { arrival: arrivalSeconds, - departure, + departure: departureSeconds, stopFor: stopForSeconds, }; } -export function formatScheduleData(scheduleData: ComputedScheduleEntry) { +export function formatScheduleData( + scheduleData: ComputedScheduleEntry +): Pick { + const arrival: IsoDurationString | null = scheduleData.arrival + ? formatDurationAsISO8601(scheduleData.arrival) + : null; + const departure: IsoDurationString | null = scheduleData.departure + ? formatDurationAsISO8601(scheduleData.departure) + : null; + const stopFor = scheduleData.stopFor !== null ? String(scheduleData.stopFor) : ''; return { - arrival: scheduleData.arrival ? secToHoursString(scheduleData.arrival, true) : '', - departure: scheduleData.departure ? secToHoursString(scheduleData.departure, true) : '', - stopFor: scheduleData.stopFor !== null ? String(scheduleData.stopFor) : '', + arrival, + departure, + stopFor, }; } diff --git a/front/src/modules/timesStops/helpers/utils.ts b/front/src/modules/timesStops/helpers/utils.ts index 31f908fdb8c..101d7c9fd6c 100644 --- a/front/src/modules/timesStops/helpers/utils.ts +++ b/front/src/modules/timesStops/helpers/utils.ts @@ -1,24 +1,39 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import dayjs from 'dayjs'; import type { TFunction } from 'i18next'; -import { round } from 'lodash'; +import { round, isEqual } from 'lodash'; import { keyColumn, createTextColumn } from 'react-datasheet-grid'; +import type { IsoDateTimeString, IsoDurationString, TimeString } from 'common/types'; import { matchPathStepAndOp } from 'modules/pathfinding/utils'; import type { OperationalPointWithTimeAndSpeed } from 'modules/trainschedule/components/DriverTrainSchedule/types'; import type { SuggestedOP } from 'modules/trainschedule/components/ManageTrainSchedule/types'; import type { PathStep } from 'reducers/osrdconf/types'; -import { extractHHMMSS } from 'utils/date'; import { NO_BREAK_SPACE } from 'utils/strings'; -import { datetime2sec, secToHoursString, time2sec } from 'utils/timeManipulation'; +import { + calculateTimeDifferenceInSeconds, + datetime2sec, + durationInSeconds, + formatDurationAsISO8601, + sec2time, + SECONDS_IN_A_DAY, + secToHoursString, + time2sec, +} from 'utils/timeManipulation'; import { marginRegExValidation, MarginUnit } from '../consts'; -import { TableType } from '../types'; -import type { PathStepOpPointCorrespondance, PathWaypointRow } from '../types'; +import { + TableType, + type TimeExtraDays, + type PathStepOpPointCorrespondance, + type PathWaypointRow, +} from '../types'; export const formatSuggestedViasToRowVias = ( operationalPoints: SuggestedOP[], pathSteps: PathStep[], t: TFunction<'timesStops', undefined>, - startTime?: string, + startTime?: IsoDateTimeString, tableType?: TableType ): PathWaypointRow[] => { const formattedOps = [...operationalPoints]; @@ -56,21 +71,23 @@ export const formatSuggestedViasToRowVias = ( const { arrival, onStopSignal, stopFor, theoreticalMargin } = objectToUse || {}; const isMarginValid = theoreticalMargin ? marginRegExValidation.test(theoreticalMargin) : true; - let departure: string | undefined; - if (stopFor) { - if (i === 0) { - departure = startTime - ? secToHoursString(datetime2sec(new Date(startTime)) + Number(stopFor), true) - : undefined; - } else if (arrival) { - departure = secToHoursString(time2sec(arrival) + Number(stopFor), true); - } - } + const durationArrivalTime = i === 0 ? 'PT0S' : arrival; + const arrivalInSeconds = durationArrivalTime ? time2sec(durationArrivalTime) : null; + + const formattedArrival = calculateStepTimeAndDays(startTime, durationArrivalTime); + + const departureTime = + stopFor && arrivalInSeconds + ? secToHoursString(arrivalInSeconds + Number(stopFor), { withSeconds: true }) + : undefined; + const formattedDeparture: TimeExtraDays | undefined = departureTime + ? { time: departureTime } + : undefined; return { ...op, isMarginValid, - arrival: i === 0 ? extractHHMMSS(startTime) : arrival, - departure, + arrival: formattedArrival, + departure: formattedDeparture, onStopSignal: onStopSignal || false, name: name || t('waypoint', { id: op.opId }), stopFor, @@ -125,3 +142,153 @@ export function disabledTextColumn( disabled: true, }; } + +/** + * Synchronizes arrival, departure and stop times. + * updates onStopSignal + * updates isMarginValid and theoreticalMargin + */ +export function updateRowTimesAndMargin( + rowData: PathWaypointRow, + previousRowData: PathWaypointRow, + op: { fromRowIndex: number }, + allWaypointsLength: number +): PathWaypointRow { + const newRowData = { ...rowData }; + if ( + !isEqual(newRowData.arrival, previousRowData.arrival) || + !isEqual(newRowData.departure, previousRowData.departure) + ) { + if (newRowData.departure?.time && newRowData.arrival?.time) { + newRowData.stopFor = String( + durationInSeconds(time2sec(newRowData.arrival.time), time2sec(newRowData.departure.time)) + ); + } else if (newRowData.departure) { + if (!previousRowData.departure) { + newRowData.arrival = { + time: sec2time(time2sec(newRowData.departure.time) - Number(newRowData.stopFor)), + }; + } else { + newRowData.departure = undefined; + } + } else if (newRowData.arrival && previousRowData.departure) { + // we just erased departure value + newRowData.stopFor = undefined; + } + } + if (!newRowData.stopFor && op.fromRowIndex !== allWaypointsLength - 1) { + newRowData.onStopSignal = false; + } + newRowData.isMarginValid = !( + newRowData.theoreticalMargin && !marginRegExValidation.test(newRowData.theoreticalMargin) + ); + if (newRowData.isMarginValid && op.fromRowIndex === 0) { + newRowData.arrival = undefined; + // As we put 0% by default for origin's margin, if the user removes a margin without + // replacing it to 0% (undefined), we change it to 0% + if (!newRowData.theoreticalMargin) { + newRowData.theoreticalMargin = '0%'; + } + } + return newRowData; +} + +/** + * This function goes through the whole array of path waypoints + * and updates the number of days since departure. + */ +export function updateDaySinceDeparture( + pathWaypointRows: PathWaypointRow[], + startTime?: IsoDateTimeString, + keepFirstIndexArrival?: boolean +): PathWaypointRow[] { + let currentDaySinceDeparture = 0; + let previousTime = startTime ? datetime2sec(new Date(startTime)) : Number.NEGATIVE_INFINITY; + + return pathWaypointRows.map((pathWaypoint, index) => { + const { arrival, stopFor } = pathWaypoint; + + const arrivalInSeconds = arrival?.time ? time2sec(arrival.time) : null; + let formattedArrival: TimeExtraDays | undefined; + if (arrivalInSeconds) { + if (arrivalInSeconds < previousTime) { + currentDaySinceDeparture += 1; + formattedArrival = { + time: arrival!.time, + daySinceDeparture: currentDaySinceDeparture, + dayDisplayed: true, + }; + } else { + formattedArrival = { + time: arrival!.time, + daySinceDeparture: currentDaySinceDeparture, + }; + } + previousTime = arrivalInSeconds; + } + + let formattedDeparture: TimeExtraDays | undefined; + if (stopFor && arrivalInSeconds) { + const departureInSeconds = (arrivalInSeconds + Number(stopFor)) % SECONDS_IN_A_DAY; + const isAfterMidnight = departureInSeconds < previousTime; + if (isAfterMidnight) { + currentDaySinceDeparture += 1; + formattedDeparture = { + time: secToHoursString(departureInSeconds, { withSeconds: true }), + daySinceDeparture: currentDaySinceDeparture, + dayDisplayed: true, + }; + } else { + formattedDeparture = { + time: secToHoursString(departureInSeconds, { withSeconds: true }), + daySinceDeparture: currentDaySinceDeparture, + }; + } + previousTime = departureInSeconds; + } + + return { + ...pathWaypoint, + arrival: keepFirstIndexArrival || index > 0 ? formattedArrival : undefined, + departure: formattedDeparture, + }; + }); +} + +export function durationSinceStartTime( + startTime?: IsoDateTimeString, + stepTimeDays?: TimeExtraDays +): IsoDurationString | null { + if (!startTime || !stepTimeDays?.time || stepTimeDays?.daySinceDeparture === undefined) { + return null; + } + const start = dayjs(startTime); + const step = dayjs(`${startTime.split('T')[0]}T${stepTimeDays.time}`).add( + stepTimeDays.daySinceDeparture, + 'day' + ); + return formatDurationAsISO8601( + calculateTimeDifferenceInSeconds(start.toISOString(), step.toISOString()) + ); +} + +export function calculateStepTimeAndDays( + startTime?: IsoDateTimeString | null, + isoDuration?: IsoDurationString | null +): TimeExtraDays | undefined { + if (!startTime || !isoDuration) { + return undefined; + } + + const start = dayjs(startTime); + const duration = dayjs.duration(isoDuration); + + const waypointArrivalTime = start.add(duration); + const daySinceDeparture = waypointArrivalTime.diff(start, 'day'); + const time: TimeString = waypointArrivalTime.format('HH:mm:ss'); + + return { + time, + daySinceDeparture, + }; +} diff --git a/front/src/modules/timesStops/hooks/useOutputTableData.ts b/front/src/modules/timesStops/hooks/useOutputTableData.ts index 2f2add01680..77a52ee91a3 100644 --- a/front/src/modules/timesStops/hooks/useOutputTableData.ts +++ b/front/src/modules/timesStops/hooks/useOutputTableData.ts @@ -72,7 +72,7 @@ function useOutputTableData( const outputTableData = useMemo(() => { const pathStepRows = pathStepsWithPositionOnPath.map((pathStep) => { const schedule = scheduleByAt[pathStep.id]; - const scheduleData = computeScheduleData(schedule, selectedTrainSchedule.start_time); + const scheduleData = computeScheduleData(schedule); return { ...pathStep, ...formatScheduleData(scheduleData) }; }); @@ -91,7 +91,7 @@ function useOutputTableData( if (pathStepKey in pathStepsByUic) { const pathStepId = pathStepsByUic[pathStepKey].id || ''; const schedule = scheduleByAt[pathStepId]; - const scheduleData = computeScheduleData(schedule, selectedTrainSchedule.start_time); + const scheduleData = computeScheduleData(schedule); const formattedScheduleData = formatScheduleData(scheduleData); const marginsData = nextOpPoint ? computeMargins(simulatedTrain, opPoint, nextOpPoint, selectedTrainSchedule, pathStepId) @@ -103,12 +103,17 @@ function useOutputTableData( onStopSignal: schedule?.on_stop_signal || '', calculatedArrival, calculatedDeparture: - opPoint.duration > 0 ? secToHoursString(opPoint.time + opPoint.duration, true) : '', + opPoint.duration > 0 + ? secToHoursString(opPoint.time + opPoint.duration, { withSeconds: true }) + : '', ...marginsData, } as SuggestedOP; } - return { ...sugOpPoint, calculatedArrival: secToHoursString(opPoint.time, true) }; + return { + ...sugOpPoint, + calculatedArrival: secToHoursString(opPoint.time, { withSeconds: true }), + }; }); return suggestedOpRows.map((sugOpPoint) => { diff --git a/front/src/modules/timesStops/hooks/useTimeStopsColumns.ts b/front/src/modules/timesStops/hooks/useTimeStopsColumns.ts index 9250714ce55..4594b00be5f 100644 --- a/front/src/modules/timesStops/hooks/useTimeStopsColumns.ts +++ b/front/src/modules/timesStops/hooks/useTimeStopsColumns.ts @@ -9,17 +9,22 @@ import type { SuggestedOP } from 'modules/trainschedule/components/ManageTrainSc import { marginRegExValidation } from '../consts'; import { disabledTextColumn } from '../helpers/utils'; +import ReadOnlyTime from '../ReadOnlyTime'; import TimeInput from '../TimeInput'; -import { TableType, type PathWaypointRow } from '../types'; +import { TableType, type PathWaypointRow, type TimeExtraDays } from '../types'; -const timeColumn: Partial> = { - component: TimeInput as CellComponent, - deleteValue: () => null, - copyValue: ({ rowData }) => rowData ?? null, - pasteValue: ({ value }) => value, - minWidth: 170, - isCellEmpty: ({ rowData }) => !rowData, -}; +const timeColumn = (isOutputTable: boolean) => + ({ + component: (isOutputTable ? ReadOnlyTime : TimeInput) as CellComponent< + TimeExtraDays | undefined, + string + >, + deleteValue: () => undefined, + copyValue: ({ rowData }) => rowData?.time ?? null, + pasteValue: ({ value }) => ({ time: value }), + minWidth: isOutputTable ? 110 : 170, + isCellEmpty: ({ rowData }) => !rowData, + }) as Partial>; const fixedWidth = (width: number) => ({ minWidth: width, maxWidth: width }); @@ -71,20 +76,19 @@ export const useTimeStopsColumns = (tableType: TableType, allWaypoints: Suggeste maxWidth: 45, }, { - ...keyColumn('arrival', isOutputTable ? createTextColumn() : timeColumn), + ...keyColumn('arrival', timeColumn(isOutputTable)), + alignRight: true, title: t('arrivalTime'), // We should not be able to edit the arrival time of the origin disabled: ({ rowIndex }) => isOutputTable || rowIndex === 0, - maxWidth: isOutputTable ? 90 : undefined, }, { - ...keyColumn('departure', isOutputTable ? createTextColumn() : timeColumn), + ...keyColumn('departure', timeColumn(isOutputTable)), title: t('departureTime'), // We should not be able to edit the departure time of the origin disabled: ({ rowIndex }) => isOutputTable || rowIndex === 0, - maxWidth: isOutputTable ? 90 : undefined, }, { ...keyColumn( @@ -105,7 +109,7 @@ export const useTimeStopsColumns = (tableType: TableType, allWaypoints: Suggeste // We should not be able to edit the reception on close signal if stopFor is not filled // except for the destination - ...fixedWidth(120), + ...fixedWidth(94), disabled: ({ rowData, rowIndex }) => isOutputTable || (rowIndex !== allWaypoints?.length - 1 && !rowData.stopFor), }, diff --git a/front/src/modules/timesStops/styles/_readOnlyTime.scss b/front/src/modules/timesStops/styles/_readOnlyTime.scss new file mode 100644 index 00000000000..9c21e150971 --- /dev/null +++ b/front/src/modules/timesStops/styles/_readOnlyTime.scss @@ -0,0 +1,4 @@ +.read-only-time { + // using px to conform with the padding in the react-datasheet-grid library class .dsg-input + padding: 0 10px; +} diff --git a/front/src/modules/timesStops/styles/_timeInput.scss b/front/src/modules/timesStops/styles/_timeInput.scss new file mode 100644 index 00000000000..d5369669282 --- /dev/null +++ b/front/src/modules/timesStops/styles/_timeInput.scss @@ -0,0 +1,20 @@ +.time-input-container { + position: relative; + width: 100%; + + input.dsg-input { + width: 100%; + } + + span.extra-text { + position: absolute; + left: 5.5rem; + top: 0.075rem; + pointer-events: none; + } + + span.extra-text-firefox { + top: 0.03rem; + left: 6.1rem; + } +} diff --git a/front/src/modules/timesStops/styles/timesStops.scss b/front/src/modules/timesStops/styles/timesStops.scss index 6eca88ff6c2..35b95184a6d 100644 --- a/front/src/modules/timesStops/styles/timesStops.scss +++ b/front/src/modules/timesStops/styles/timesStops.scss @@ -1 +1,3 @@ @use './timesStopsDatasheet'; +@use './timeInput'; +@use './readOnlyTime'; diff --git a/front/src/modules/timesStops/types.ts b/front/src/modules/timesStops/types.ts index ddc8d96c66e..fb7cf1e56ab 100644 --- a/front/src/modules/timesStops/types.ts +++ b/front/src/modules/timesStops/types.ts @@ -1,9 +1,18 @@ import type { TrainScheduleBase, TrainScheduleResult } from 'common/api/osrdEditoastApi'; +import type { TimeString } from 'common/types'; import type { SuggestedOP } from 'modules/trainschedule/components/ManageTrainSchedule/types'; import type { ArrayElement } from 'utils/types'; -export type PathWaypointRow = SuggestedOP & { +export type TimeExtraDays = { + time: TimeString; + daySinceDeparture?: number; + dayDisplayed?: boolean; +}; + +export type PathWaypointRow = Omit & { isMarginValid: boolean; + arrival?: TimeExtraDays; // value asked by user + departure?: TimeExtraDays; // value asked by user }; export enum TableType { diff --git a/front/src/modules/trainschedule/components/ImportTrainSchedule/ImportTrainScheduleTrainDetail.tsx b/front/src/modules/trainschedule/components/ImportTrainSchedule/ImportTrainScheduleTrainDetail.tsx index c816e0bde74..627aeee6a2b 100644 --- a/front/src/modules/trainschedule/components/ImportTrainSchedule/ImportTrainScheduleTrainDetail.tsx +++ b/front/src/modules/trainschedule/components/ImportTrainSchedule/ImportTrainScheduleTrainDetail.tsx @@ -34,7 +34,7 @@ export default function ImportTrainScheduleTrainDetail({ const durationInSecond = Math.round( (new Date(arrivalTime).getTime() - new Date(departureTime).getTime()) / 1000 ); - return secToHoursString(durationInSecond, true); + return secToHoursString(durationInSecond, { withSeconds: true }); }; return (
{ + describe('same day', () => { + it('should ignore steps without arrival or stopFor', () => { + const pathSteps: PathStep[] = [ + { + id: 'id331', + deleted: false, + uic: 8706, + ch: 'BV', + kp: '130+538', + name: 'G', + positionOnPath: 0, + }, + ]; + const result = formatSchedule(pathSteps); + expect(result?.length).toBe(0); + }); + it('should format the train schedule', () => { + const pathSteps: PathStep[] = [ + { + id: 'id332', + deleted: false, + uic: 8737, + ch: 'BV', + kp: '117+422', + name: 'V', + positionOnPath: 13116000, + arrival: 'PT60S', + stopFor: '0', + locked: false, + onStopSignal: false, + }, + ]; + const result = formatSchedule(pathSteps); + expect(result).toEqual([ + { + arrival: 'PT60S', + at: 'id332', + locked: false, + on_stop_signal: false, + stop_for: 'PT0S', + }, + ]); + }); + }); +}); diff --git a/front/src/modules/trainschedule/components/ManageTrainSchedule/helpers/checkCurrentConfig.ts b/front/src/modules/trainschedule/components/ManageTrainSchedule/helpers/checkCurrentConfig.ts index 9c50cb15349..081dd826b99 100644 --- a/front/src/modules/trainschedule/components/ManageTrainSchedule/helpers/checkCurrentConfig.ts +++ b/front/src/modules/trainschedule/components/ManageTrainSchedule/helpers/checkCurrentConfig.ts @@ -145,7 +145,8 @@ const checkCurrentConfig = ( rollingStockComfort, initialSpeed: initialSpeed ? kmhToMs(initialSpeed) : 0, usingElectricalProfiles, - path: compact(osrdconf.pathSteps).map((step) => { + path: compact(pathSteps).map((step) => { + // TODO use lodash pick const { arrival, locked, @@ -171,7 +172,7 @@ const checkCurrentConfig = ( }), margins: formatMargin(compact(pathSteps)), - schedule: formatSchedule(compact(pathSteps), startTime), + schedule: formatSchedule(compact(pathSteps)), powerRestrictions: powerRestriction, firstStartTime: startTime, speedLimitByTag, diff --git a/front/src/modules/trainschedule/components/ManageTrainSchedule/helpers/formatSchedule.ts b/front/src/modules/trainschedule/components/ManageTrainSchedule/helpers/formatSchedule.ts index 11b4bdd4c03..5d9990d0213 100644 --- a/front/src/modules/trainschedule/components/ManageTrainSchedule/helpers/formatSchedule.ts +++ b/front/src/modules/trainschedule/components/ManageTrainSchedule/helpers/formatSchedule.ts @@ -2,34 +2,14 @@ import { compact, isNaN, isNil } from 'lodash'; import type { TrainScheduleBase } from 'common/api/osrdEditoastApi'; import type { PathStep } from 'reducers/osrdconf/types'; -import { - datetime2sec, - durationInSeconds, - formatDurationAsISO8601, - time2sec, -} from 'utils/timeManipulation'; +import { formatDurationAsISO8601 } from 'utils/timeManipulation'; -const formatSchedule = ( - pathSteps: PathStep[], - startTime: string -): TrainScheduleBase['schedule'] => { +const formatSchedule = (pathSteps: PathStep[]): TrainScheduleBase['schedule'] => { const schedules = pathSteps.map((step) => { - let formatArrival; - if (step.arrival || step.stopFor) { - if (step.arrival) { - // Duration in seconds between startTime and step.arrival - const durationStartTimeArrival = durationInSeconds( - datetime2sec(new Date(startTime)), - time2sec(step.arrival) - ); - - // Format duration in ISO8601 - formatArrival = formatDurationAsISO8601(durationStartTimeArrival); - } - + if (step?.arrival || step.stopFor) { return { at: step.id, - arrival: formatArrival ?? undefined, + arrival: step.arrival ?? undefined, locked: step.locked, on_stop_signal: step.onStopSignal, stop_for: diff --git a/front/src/modules/trainschedule/components/ManageTrainSchedule/types.ts b/front/src/modules/trainschedule/components/ManageTrainSchedule/types.ts index 4ff56ba52d2..40257235704 100644 --- a/front/src/modules/trainschedule/components/ManageTrainSchedule/types.ts +++ b/front/src/modules/trainschedule/components/ManageTrainSchedule/types.ts @@ -1,6 +1,7 @@ import type { Position } from 'geojson'; import type { TrainScheduleBase } from 'common/api/osrdEditoastApi'; +import type { IsoDurationString } from 'common/types'; export type SuggestedOP = { opId: string; @@ -21,8 +22,8 @@ export type SuggestedOP = { It's useful for soft deleting the point (waiting to fix / remove all references) If true, the train schedule is consider as invalid and must be edited */ deleted?: boolean; - arrival?: string | null; // value asked by user - departure?: string | null; // value asked by user + arrival?: IsoDurationString | null; // value asked by user, number of seconds since departure + departure?: IsoDurationString | null; // value asked by user, number of seconds since departure locked?: boolean; stopFor?: string | null; // value asked by user theoreticalMargin?: string; // value asked by user diff --git a/front/src/reducers/osrdconf/helpers.ts b/front/src/reducers/osrdconf/helpers.ts index 52bd1bcbec3..a643ec6992b 100644 --- a/front/src/reducers/osrdconf/helpers.ts +++ b/front/src/reducers/osrdconf/helpers.ts @@ -1,8 +1,11 @@ import { feature, point } from '@turf/helpers'; -import { last } from 'lodash'; +import { compact, last, pick } from 'lodash'; +import nextId from 'react-id-generator'; import { calculateDistanceAlongTrack } from 'applications/editor/tools/utils'; import type { ManageTrainSchedulePathProperties } from 'applications/operationalStudies/types'; +import { pathStepMatchesOp } from 'modules/pathfinding/utils'; +import type { SuggestedOP } from 'modules/trainschedule/components/ManageTrainSchedule/types'; import { addElementAtIndex, replaceElementAtIndex } from 'utils/array'; import { formatIsoDate } from 'utils/date'; import { sec2time, time2sec } from 'utils/timeManipulation'; @@ -142,3 +145,47 @@ export const updateDestinationPathStep = ( destination: Partial | null, replaceCompletely: boolean = false ) => updatePathStepAtIndex(pathSteps, pathSteps.length - 1, destination, replaceCompletely); + +/** + * Modifies the array statePathSteps in place in the reducer + */ +export function upsertPathStep(statePathSteps: (PathStep | null)[], op: SuggestedOP) { + // We know that, at this point, origin and destination are defined because pathfinding has been done + const cleanPathSteps = compact(statePathSteps); + + let newVia: PathStep = { + ...pick(op, [ + 'coordinates', + 'positionOnPath', + 'name', + 'ch', + 'kp', + 'stopFor', + 'arrival', + 'locked', + 'deleted', + 'onStopSignal', + 'theoreticalMargin', + ]), + id: nextId(), + ...(op.uic + ? { uic: op.uic } + : { + track: op.track, + offset: op.offsetOnTrack, + }), + }; + + const stepIndex = cleanPathSteps.findIndex((step) => pathStepMatchesOp(step, op)); + if (stepIndex >= 0) { + // Because of import issues, there can be multiple ops with same position on path + // To avoid updating the wrong one, we need to find the one that matches the payload + newVia = { ...newVia, id: cleanPathSteps[stepIndex].id }; // We don't need to change the id of the updated via + statePathSteps[stepIndex] = newVia; + } else { + // Because of import issues, there can be multiple ops at position 0 + // To avoid inserting a new via before the origin we need to check if the index is 0 + const index = cleanPathSteps.findIndex((step) => step.positionOnPath! >= op.positionOnPath); + statePathSteps.splice(index || 1, 0, newVia); + } +} diff --git a/front/src/reducers/osrdconf/osrdConfCommon/index.ts b/front/src/reducers/osrdconf/osrdConfCommon/index.ts index af598b17080..738a030939d 100644 --- a/front/src/reducers/osrdconf/osrdConfCommon/index.ts +++ b/front/src/reducers/osrdconf/osrdConfCommon/index.ts @@ -1,11 +1,9 @@ import type { CaseReducer, PayloadAction, PrepareAction } from '@reduxjs/toolkit'; import type { Draft } from 'immer'; -import { compact, omit } from 'lodash'; -import nextId from 'react-id-generator'; +import { omit } from 'lodash'; import type { ManageTrainSchedulePathProperties } from 'applications/operationalStudies/types'; import { ArrivalTimeTypes } from 'applications/stdcmV2/types'; -import { isVia } from 'modules/pathfinding/utils'; import type { SuggestedOP } from 'modules/trainschedule/components/ManageTrainSchedule/types'; import { type InfraStateReducers, buildInfraStateReducers, infraState } from 'reducers/infra'; import { @@ -13,6 +11,7 @@ import { insertViaFromMap, updateDestinationPathStep, updateOriginPathStep, + upsertPathStep, } from 'reducers/osrdconf/helpers'; import type { OperationalStudiesConfSlice, @@ -22,7 +21,7 @@ import type { OperationalStudiesConfSelectors } from 'reducers/osrdconf/operatio import type { StdcmConfSlice, StdcmConfSliceActions } from 'reducers/osrdconf/stdcmConf'; import type { StdcmConfSelectors } from 'reducers/osrdconf/stdcmConf/selectors'; import type { OsrdConfState, PathStep } from 'reducers/osrdconf/types'; -import { addElementAtIndex, removeElementAtIndex, replaceElementAtIndex } from 'utils/array'; +import { removeElementAtIndex } from 'utils/array'; import { formatIsoDate } from 'utils/date'; import type { ArrayElement } from 'utils/types'; @@ -99,6 +98,7 @@ interface CommonConfReducers extends InfraStateReducers prepare: PrepareAction; }; ['upsertViaFromSuggestedOP']: CaseReducer>; + ['upsertSeveralViasFromSuggestedOP']: CaseReducer>; ['updateRollingStockComfort']: CaseReducer>; ['updateStartTime']: CaseReducer>; ['updateOrigin']: CaseReducer>>; @@ -288,53 +288,12 @@ export function buildCommonConfReducers(): CommonConfRe // Use this action to transform an op to via from times and stop table or // from the suggested via modal upsertViaFromSuggestedOP(state: Draft, action: PayloadAction) { - // We know that, at this point, origin and destination are defined because pathfinding has been done - const pathSteps = compact(state.pathSteps); - - let newVia: PathStep = { - coordinates: action.payload.coordinates, - id: nextId(), - positionOnPath: action.payload.positionOnPath, - name: action.payload.name, - ch: action.payload.ch, - kp: action.payload.kp, - stopFor: action.payload.stopFor, - arrival: action.payload.arrival, - locked: action.payload.locked, - deleted: action.payload.deleted, - onStopSignal: action.payload.onStopSignal, - theoreticalMargin: action.payload.theoreticalMargin, - ...(action.payload.uic - ? { uic: action.payload.uic } - : { - track: action.payload.track, - offset: action.payload.offsetOnTrack, - }), - }; - - const isInVias = isVia(pathSteps, action.payload); - if (isInVias) { - // Because of import issues, there can be multiple ops with same position on path - // To avoid updating the wrong one, we need to find the one that matches the payload - const stepIndex = pathSteps.findIndex( - (step) => - ('uic' in step && - 'ch' in step && - step.uic === action.payload.uic && - step.ch === action.payload.ch && - step.name === action.payload.name) || - step.id === action.payload.opId - ); - newVia = { ...newVia, id: pathSteps[stepIndex].id }; // We don't need to change the id of the updated via - state.pathSteps = replaceElementAtIndex(state.pathSteps, stepIndex, newVia); - } else { - const index = pathSteps.findIndex( - (step) => step.positionOnPath! >= action.payload.positionOnPath - ); - // Because of import issues, there can be multiple ops at position 0 - // To avoid inserting a new via before the origin we need to check if the index is 0 - state.pathSteps = addElementAtIndex(state.pathSteps, index || 1, newVia); - } + upsertPathStep(state.pathSteps, action.payload); + }, + upsertSeveralViasFromSuggestedOP(state: Draft, action: PayloadAction) { + action.payload.forEach((suggestedOp) => { + upsertPathStep(state.pathSteps, suggestedOp); + }); }, updateRollingStockComfort(state: Draft, action: PayloadAction) { state.rollingStockComfort = action.payload; diff --git a/front/src/reducers/osrdconf/types.ts b/front/src/reducers/osrdconf/types.ts index d0676b10dc7..fedebf2f1e2 100644 --- a/front/src/reducers/osrdconf/types.ts +++ b/front/src/reducers/osrdconf/types.ts @@ -4,6 +4,7 @@ import type { PowerRestriction } from 'applications/operationalStudies/types'; import type { AllowanceValue } from 'applications/stdcm/types'; import type { ArrivalTimeTypes } from 'applications/stdcmV2/types'; import type { Comfort, Distribution, PathItemLocation } from 'common/api/osrdEditoastApi'; +import type { IsoDurationString } from 'common/types'; import type { InfraState } from 'reducers/infra'; export interface OsrdConfState extends InfraState { @@ -55,7 +56,7 @@ export type PathStep = PathItemLocation & { It's useful for soft deleting the point (waiting to fix / remove all references) If true, the train schedule is consider as invalid and must be edited */ deleted?: boolean; - arrival?: string | null; + arrival?: IsoDurationString | null; arrivalType?: ArrivalTimeTypes; arrivalToleranceBefore?: number; arrivalToleranceAfter?: number; diff --git a/front/src/utils/date.ts b/front/src/utils/date.ts index 558347d58a6..07b14482cc7 100644 --- a/front/src/utils/date.ts +++ b/front/src/utils/date.ts @@ -4,6 +4,7 @@ import customParseFormat from 'dayjs/plugin/customParseFormat'; import timezone from 'dayjs/plugin/timezone'; import utc from 'dayjs/plugin/utc'; +import type { IsoDateTimeString, IsoDurationString } from 'common/types'; import i18n from 'i18n'; import { ISO8601Duration2sec } from './timeManipulation'; @@ -164,11 +165,14 @@ export function convertUTCDateToLocalDate(date: number) { return Math.abs(timeDifferenceMinutes) * 60 + date; } -export function convertIsoUtcToLocalTime(isoUtcString: string): string { +export function convertIsoUtcToLocalTime(isoUtcString: IsoDateTimeString): string { return dayjs(isoUtcString).local().format(); } -export function addDurationToIsoDate(startTime: string, duration: string) { +export function addDurationToIsoDate( + startTime: IsoDateTimeString, + duration: IsoDurationString +): IsoDateTimeString { return dayjs(startTime).add(ISO8601Duration2sec(duration), 'second').format(); } diff --git a/front/src/utils/timeManipulation.ts b/front/src/utils/timeManipulation.ts index ca34e76de2e..53723f4114b 100644 --- a/front/src/utils/timeManipulation.ts +++ b/front/src/utils/timeManipulation.ts @@ -6,6 +6,8 @@ import type { TimeString } from 'common/types'; dayjs.extend(duration); +export const SECONDS_IN_A_DAY = 86400; + export function sec2ms(sec: number) { return sec * 1000; } @@ -53,7 +55,7 @@ export function datetime2sec(time: Date): number { } export function durationInSeconds(start: number, end: number) { - return end > start ? end - start : end + 86400 - start; + return end > start ? end - start : end + SECONDS_IN_A_DAY - start; } export function calculateTimeDifferenceInSeconds(time1: string | Date, time2: string | Date) { @@ -79,12 +81,10 @@ export function ISO8601Duration2sec(isoDuration: string) { * * using the param withSeconds returns the longer format "HH:MM:SS" */ -export function secToHoursString(sec: number | null, withSeconds = false): TimeString { +export function secToHoursString(sec: number | null, { withSeconds = false } = {}): TimeString { if (!sec) { return ''; } const format = withSeconds ? '%H:%M:%S' : '%H:%M'; return d3.utcFormat(format)(new Date(sec * 1000)); } - -export const SECONDS_IN_A_DAY = 86400;