Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 55 additions & 22 deletions front/src/modules/timesStops/DurationCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useLayoutEffect,
useEffect,
useImperativeHandle,
Fragment,
type Dispatch,
} from 'react';

Expand Down Expand Up @@ -344,9 +345,17 @@ type UnitDisplayProps = {
dispatch: Dispatch<DurationAction>;
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';
Expand Down Expand Up @@ -386,7 +395,7 @@ const UnitDisplay = ({ unit, state, dispatch, startEditing, isEdited }: UnitDisp
</>
)}
</span>
{unit && <span className={letterClass}>{unit}</span>}
{label && <span className={letterClass}>{label}</span>}
</span>
);
};
Expand All @@ -395,18 +404,31 @@ export type DurationCellHandle = {
focus: () => void;
};

const DurationCell = ({
disabled,
clearButtonTitle,
ref,
...props
}: CellContext<TimesStopsRowNew, Duration | null> &
type DurationCellProps = CellContext<TimesStopsRowNew, Duration | null> &
Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> & {
prefillValue?: Duration | null;
onEnterKeyDown?: () => void;
onTabKeyDown?: (direction: 'forward' | 'backward') => boolean;
onCommit?: (seconds: number | null, propagationMode: StopPropagationMode) => void;
disabled?: boolean;
clearButtonTitle?: string;
disableClear?: boolean;
ref?: React.Ref<DurationCellHandle>;
}) => {
/** Display the duration as a digital clock, e.g. "42:53:04" */
digital?: boolean;
};

const DurationCell = ({
prefillValue,
disabled,
clearButtonTitle,
ref,
onEnterKeyDown,
onTabKeyDown,
disableClear,
digital,
...props
}: DurationCellProps) => {
const { onCommit, getValue, row, table } = props || {};
const controlledValue = getValue();
const [state, dispatch] = useReducer(durationReducer, controlledValue, initialDurationState);
Expand Down Expand Up @@ -438,8 +460,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 },
Expand Down Expand Up @@ -469,14 +492,15 @@ const DurationCell = ({
containerRef.current?.blur();
};

const handleKeyDown = (e: React.KeyboardEvent) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!state.isEditing) return;

switch (e.key) {
case 'Enter':
e.preventDefault();
blurHandledRef.current = true;
commit();
onEnterKeyDown?.();
containerRef.current?.blur();
break;
case 'Escape':
Expand All @@ -485,6 +509,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' });
Expand Down Expand Up @@ -547,15 +577,18 @@ const DurationCell = ({
<CellPlaceholder onClick={() => {}} />
) : (
<>
{UNITS.map((unit) => (
<UnitDisplay
key={unit}
unit={unit}
state={state}
dispatch={dispatch}
startEditing={startEditing}
isEdited={isEdited}
/>
{UNITS.map((unit, index) => (
<Fragment key={unit}>
{digital && index > 0 ? ':' : null}
<UnitDisplay
unit={unit}
label={digital ? '' : unit}
state={state}
dispatch={dispatch}
startEditing={startEditing}
isEdited={isEdited}
/>
</Fragment>
))}
</>
)}
Expand All @@ -570,7 +603,7 @@ const DurationCell = ({
/>
</div>
<ClearButton
isVisible={state.isEditing}
isVisible={state.isEditing && !disableClear}
title={clearButtonTitle}
containerRef={containerRef}
onClear={handleClear}
Expand Down
86 changes: 86 additions & 0 deletions front/src/modules/timesStops/StartTimeCell.tsx
Original file line number Diff line number Diff line change
@@ -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<TimesStopsRowNew, StartTime | null>;
} & React.InputHTMLAttributes<HTMLInputElement> & {
/** 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<TimeCellHandle | DurationCellHandle>;
};

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<TimesStopsRowNew, Date | null>;
return (
<TimeCell
{...timeCellContext}
{...props}
referenceDate={referenceDate}
prefillValue={prefillValue}
clearButtonTitle={clearButtonTitle}
onEnterKeyDown={onEnterKeyDown}
onTabKeyDown={onTabKeyDown}
onCommit={onCommit}
disableClear={disableClear}
/>
);
} 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<TimesStopsRowNew, Duration | null>;
return (
<DurationCell
{...durationCellContext}
{...props}
prefillValue={prefillValue}
clearButtonTitle={clearButtonTitle}
onEnterKeyDown={onEnterKeyDown}
onTabKeyDown={onTabKeyDown}
onCommit={(seconds, propagationMode) => {
const dur = seconds !== null ? new Duration({ seconds }) : null;
onCommit?.(dur, propagationMode);
}}
disableClear={disableClear}
digital={true}
/>
);
}
};

export default StartTimeCell;
33 changes: 22 additions & 11 deletions front/src/modules/timesStops/TimeStopsTableWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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,
};
}
Expand All @@ -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)
Expand Down Expand Up @@ -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 ?? {}),
Expand Down Expand Up @@ -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 }]
: [];
};
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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 };
Expand All @@ -315,7 +326,7 @@ const TimeStopsTableWrapper = ({

const handleDepartureChange = (
row: TimesStopsRowNew,
departure: Date | null,
departure: StartTime | null,
propagationMode: PropagationMode
) => {
const singleEdit: PendingEdit = {
Expand Down
Loading