Update various improvements and minor changes

This commit is contained in:
2026-02-02 17:35:11 +00:00
parent 02b1209c2d
commit f4260061f5
10 changed files with 308 additions and 181 deletions

View File

@@ -165,7 +165,10 @@ const MedPlanAssistant = () => {
onImportDays={(importedDays: any) => updateState('days', importedDays)} onImportDays={(importedDays: any) => updateState('days', importedDays)}
/> />
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto" style={{
// TODO ideally we would have a value around 320px or similar for mobile devices but this causes layout issues (consider e.g. wrapping) and makes the chart hard to read
minWidth: '410px' //minWidth: '320px'
}}>
<header className="mb-8"> <header className="mb-8">
<div className="flex justify-between items-start gap-4"> <div className="flex justify-between items-start gap-4">
<div className=""> <div className="">

View File

@@ -201,7 +201,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
{!collapsedDays.has(day.id) && ( {!collapsedDays.has(day.id) && (
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{/* Dose table header */} {/* Dose table header */}
<div className="grid grid-cols-[100px_1fr_auto_auto] gap-2 text-sm font-medium text-muted-foreground"> <div className="grid grid-cols-[120px_1fr_auto] sm:grid-cols-[120px_1fr_auto_auto] gap-2 text-sm font-medium text-muted-foreground">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span>{t('time')}</span> <span>{t('time')}</span>
<Tooltip> <Tooltip>
@@ -229,10 +229,10 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
</Tooltip> </Tooltip>
</div> </div>
<div>{t('ldx')} (mg)</div> <div>{t('ldx')} (mg)</div>
<div className="text-center"> {/* <div className="sm:text-center">
<Utensils className="h-4 w-4 inline" /> <Utensils className="h-4 w-4 inline" />
</div> </div> */}
<div className="invisible">-</div> <div className="hidden sm:block invisible">-</div>
</div> </div>
{/* Dose rows */} {/* Dose rows */}
@@ -245,7 +245,8 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
const isZeroDose = dose.ldx === '0' || dose.ldx === '0.0'; const isZeroDose = dose.ldx === '0' || dose.ldx === '0.0';
return ( return (
<div key={dose.id} className="grid grid-cols-[120px_1fr_auto_auto] gap-2 items-center"> <div key={dose.id} className="space-y-2">
<div className="grid grid-cols-[120px_1fr_auto] sm:grid-cols-[120px_1fr_auto_auto] gap-2 items-center">
<FormTimeInput <FormTimeInput
value={dose.time} value={dose.time}
onChange={(value) => onUpdateDose(day.id, dose.id, 'time', value)} onChange={(value) => onUpdateDose(day.id, dose.id, 'time', value)}
@@ -259,12 +260,15 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
onChange={(value) => onUpdateDose(day.id, dose.id, 'ldx', value)} onChange={(value) => onUpdateDose(day.id, dose.id, 'ldx', value)}
increment={doseIncrement} increment={doseIncrement}
min={0} min={0}
unit="mg" max={200}
//unit="mg"
required={true} required={true}
warning={isZeroDose} warning={isZeroDose}
errorMessage={t('errorNumberRequired')} errorMessage={t('errorNumberRequired')}
warningMessage={t('warningZeroDose')} warningMessage={t('warningZeroDose')}
inputWidth="w-[72px]"
/> />
<div className="flex gap-2 sm:contents">
<IconButtonWithTooltip <IconButtonWithTooltip
onClick={() => onUpdateDoseField(day.id, dose.id, 'isFed', !dose.isFed)} onClick={() => onUpdateDoseField(day.id, dose.id, 'isFed', !dose.isFed)}
icon={<Utensils className="h-4 w-4" />} icon={<Utensils className="h-4 w-4" />}
@@ -283,6 +287,8 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
className="h-9 w-9 p-0 border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:border-muted" className="h-9 w-9 p-0 border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:border-muted"
/> />
</div> </div>
</div>
</div>
); );
})} })}

View File

@@ -403,6 +403,7 @@ const Settings = ({
onChange={val => onUpdateTherapeuticRange('min', val)} onChange={val => onUpdateTherapeuticRange('min', val)}
increment={0.5} increment={0.5}
min={0} min={0}
max={500}
placeholder={t('min')} placeholder={t('min')}
required={true} required={true}
error={!!therapeuticRangeError || !therapeuticRange.min} error={!!therapeuticRangeError || !therapeuticRange.min}
@@ -414,6 +415,7 @@ const Settings = ({
onChange={val => onUpdateTherapeuticRange('max', val)} onChange={val => onUpdateTherapeuticRange('max', val)}
increment={0.5} increment={0.5}
min={0} min={0}
max={500}
placeholder={t('max')} placeholder={t('max')}
unit="ng/ml" unit="ng/ml"
required={true} required={true}
@@ -485,6 +487,7 @@ const Settings = ({
onChange={val => onUpdateUiSetting('yAxisMin', val)} onChange={val => onUpdateUiSetting('yAxisMin', val)}
increment={1} increment={1}
min={0} min={0}
max={500}
placeholder={t('auto')} placeholder={t('auto')}
allowEmpty={true} allowEmpty={true}
clearButton={true} clearButton={true}
@@ -497,6 +500,7 @@ const Settings = ({
onChange={val => onUpdateUiSetting('yAxisMax', val)} onChange={val => onUpdateUiSetting('yAxisMax', val)}
increment={1} increment={1}
min={0} min={0}
max={500}
placeholder={t('auto')} placeholder={t('auto')}
unit="ng/ml" unit="ng/ml"
allowEmpty={true} allowEmpty={true}
@@ -688,7 +692,7 @@ const Settings = ({
onChange={val => onUpdatePkParams('damph', { ...pkParams.damph, halfLife: val })} onChange={val => onUpdatePkParams('damph', { ...pkParams.damph, halfLife: val })}
increment={0.5} increment={0.5}
min={5} min={5}
max={34} max={50}
unit="h" unit="h"
required={true} required={true}
warning={eliminationWarning && !eliminationExtreme} warning={eliminationWarning && !eliminationExtreme}
@@ -726,7 +730,7 @@ const Settings = ({
onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, halfLife: val })} onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, halfLife: val })}
increment={0.1} increment={0.1}
min={0.5} min={0.5}
max={2} max={5}
unit="h" unit="h"
required={true} required={true}
warning={conversionWarning} warning={conversionWarning}
@@ -760,7 +764,7 @@ const Settings = ({
onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, absorptionHalfLife: val })} onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, absorptionHalfLife: val })}
increment={0.1} increment={0.1}
min={0.5} min={0.5}
max={2} max={5}
unit="h" unit="h"
required={true} required={true}
warning={absorptionWarning} warning={absorptionWarning}
@@ -836,7 +840,7 @@ const Settings = ({
onChange={val => updateAdvanced('standardVd', 'customValue', val)} onChange={val => updateAdvanced('standardVd', 'customValue', val)}
increment={10} increment={10}
min={50} min={50}
max={800} max={2000}
unit="L" unit="L"
required={true} required={true}
/> />
@@ -905,7 +909,7 @@ const Settings = ({
onChange={val => updateAdvanced('weightBasedVd', 'bodyWeight', val)} onChange={val => updateAdvanced('weightBasedVd', 'bodyWeight', val)}
increment={1} increment={1}
min={20} min={20}
max={150} max={300}
unit={t('bodyWeightUnit')} unit={t('bodyWeightUnit')}
required={true} required={true}
/> />
@@ -943,7 +947,7 @@ const Settings = ({
onChange={val => updateAdvanced('foodEffect', 'tmaxDelay', val)} onChange={val => updateAdvanced('foodEffect', 'tmaxDelay', val)}
increment={0.1} increment={0.1}
min={0} min={0}
max={2} max={5}
unit={t('tmaxDelayUnit')} unit={t('tmaxDelayUnit')}
required={true} required={true}
/> />

View File

@@ -67,6 +67,7 @@ const SimulationChart = ({
}: any) => { }: any) => {
const totalHours = (parseInt(simulationDays, 10) || 3) * 24; const totalHours = (parseInt(simulationDays, 10) || 3) * 24;
const dispDays = parseInt(displayedDays, 10) || 2; const dispDays = parseInt(displayedDays, 10) || 2;
const simDays = parseInt(simulationDays, 10) || 3;
// Calculate chart dimensions // Calculate chart dimensions
const [containerWidth, setContainerWidth] = React.useState(1000); const [containerWidth, setContainerWidth] = React.useState(1000);
@@ -84,9 +85,19 @@ const SimulationChart = ({
return () => window.removeEventListener('resize', updateWidth); return () => window.removeEventListener('resize', updateWidth);
}, []); }, []);
// Y-axis takes ~80px, scrollable area gets the rest
const yAxisWidth = 80;
const scrollableWidth = containerWidth - yAxisWidth;
// Calculate chart width for scrollable area
const chartWidth = simDays <= dispDays
? scrollableWidth
: Math.ceil((scrollableWidth / dispDays) * simDays);
// Use shorter captions on narrow containers to reduce wrapping // Use shorter captions on narrow containers to reduce wrapping
const isCompactLabels = containerWidth < 640; // tweakable threshold for mobile const isCompactLabels = containerWidth < 640; // tweakable threshold for mobile
// Precompute series labels with translations
const seriesLabels = React.useMemo<Record<string, { full: string; short: string; display: string }>>(() => { const seriesLabels = React.useMemo<Record<string, { full: string; short: string; display: string }>>(() => {
const damphFull = t('dAmphetamine'); const damphFull = t('dAmphetamine');
const damphShort = t('dAmphetamineShort', { defaultValue: damphFull }); const damphShort = t('dAmphetamineShort', { defaultValue: damphFull });
@@ -121,15 +132,10 @@ const SimulationChart = ({
}; };
}, [isCompactLabels, t]); }, [isCompactLabels, t]);
const simDays = parseInt(simulationDays, 10) || 3;
// Y-axis takes ~80px, scrollable area gets the rest
const yAxisWidth = 80;
const scrollableWidth = containerWidth - yAxisWidth;
// Dynamically calculate tick interval based on available pixel width // Dynamically calculate tick interval based on available pixel width
// Aim for ~46px per label to avoid overlaps on narrow screens
const xTickInterval = React.useMemo(() => { const xTickInterval = React.useMemo(() => {
// Aim for ~46px per label to avoid overlaps on narrow screens
//const MIN_PX_PER_TICK = 46;
const MIN_PX_PER_TICK = 46; const MIN_PX_PER_TICK = 46;
const intervals = [1, 2, 3, 4, 6, 8, 12, 24]; const intervals = [1, 2, 3, 4, 6, 8, 12, 24];
@@ -146,8 +152,8 @@ const SimulationChart = ({
return selected ?? 24; return selected ?? 24;
}, [dispDays, scrollableWidth]); }, [dispDays, scrollableWidth]);
// Generate ticks for continuous time axis // Generate x-axis ticks for continuous time axis
const chartTicks = React.useMemo(() => { const xAxisTicks = React.useMemo(() => {
const ticks = []; const ticks = [];
for (let i = 0; i <= totalHours; i += xTickInterval) { for (let i = 0; i <= totalHours; i += xTickInterval) {
ticks.push(i); ticks.push(i);
@@ -155,7 +161,38 @@ const SimulationChart = ({
return ticks; return ticks;
}, [totalHours, xTickInterval]); }, [totalHours, xTickInterval]);
const chartDomain = React.useMemo(() => { // Custom tick renderer for x-axis to handle 12h/24h/continuous formats
const XAxisTick = (props: any) => {
const { x, y, payload } = props;
const h = payload.value as number;
let label: string;
if (showDayTimeOnXAxis === '24h') {
label = `${h % 24}${t('unitHour')}`;
} else if (showDayTimeOnXAxis === '12h') {
const hour12 = h % 24;
if (hour12 === 12) {
label = t('tickNoon');
return (
<text x={x} y={y + 12} textAnchor="middle" fontStyle="italic" fill="#666">
{label}
</text>
);
}
const displayHour = hour12 === 0 ? 12 : hour12 > 12 ? hour12 - 12 : hour12;
const period = hour12 < 12 ? 'a' : 'p';
label = `${displayHour}${period}`;
} else {
label = `${h}`;
}
return (
<text x={x} y={y + 12} textAnchor="middle" fill="#666">
{label}
</text>
);
};
// Calculate Y-axis domain based on data and user settings
const yAxisDomain = React.useMemo(() => {
const numMin = parseFloat(yAxisMin); const numMin = parseFloat(yAxisMin);
const numMax = parseFloat(yAxisMax); const numMax = parseFloat(yAxisMax);
@@ -195,9 +232,9 @@ const chartDomain = React.useMemo(() => {
// User set yAxisMin explicitly // User set yAxisMin explicitly
domainMin = numMin; domainMin = numMin;
} else if (dataMin !== Infinity) { // data exists } else if (dataMin !== Infinity) { // data exists
// Auto mode: add 5% padding below so the line is not flush with x-axis // Auto mode: add 10% padding below so the line is not flush with x-axis
const range = dataMax - dataMin; const range = dataMax - dataMin;
const padding = range * 0.05; const padding = range * 0.1;
domainMin = Math.max(0, dataMin - padding); domainMin = Math.max(0, dataMin - padding);
} else { // no data } else { // no data
domainMin = 0; domainMin = 0;
@@ -206,12 +243,22 @@ const chartDomain = React.useMemo(() => {
// Calculate final domain max // Calculate final domain max
let domainMax: number; let domainMax: number;
if (!isNaN(numMax)) { // max value provided via settings if (!isNaN(numMax)) { // max value provided via settings
// User set yAxisMax explicitly - use it as-is without padding if (dataMax !== -Infinity) {
domainMax = numMax; // User set yAxisMax explicitly
} else if (dataMax !== -Infinity) { // data exists // Add padding to dataMax and use the higher of manual or (dataMax + padding)
// Auto mode: add 5% padding above
const range = dataMax - dataMin; const range = dataMax - dataMin;
const padding = range * 0.05; const padding = range * 0.1;
const dataMaxWithPadding = dataMax + padding;
// Use manual max only if it's higher than dataMax + padding
domainMax = Math.max(numMax, dataMaxWithPadding);
} else {
// No data, use manual max as-is
domainMax = numMax;
}
} else if (dataMax !== -Infinity) { // data exists
// Auto mode: add 10% padding above
const range = dataMax - dataMin;
const padding = range * 0.1;
domainMax = dataMax + padding; domainMax = dataMax + padding;
} else { // no data } else { // no data
domainMax = 100; domainMax = 100;
@@ -220,14 +267,14 @@ const chartDomain = React.useMemo(() => {
return [domainMin, domainMax]; return [domainMin, domainMax];
}, [yAxisMin, yAxisMax, combinedProfile, templateProfile, chartView]); }, [yAxisMin, yAxisMax, combinedProfile, templateProfile, chartView]);
// Check which days have deviations (differ from template) // Check which days have deviations (differ from regular plan)
const daysWithDeviations = React.useMemo(() => { const daysWithDeviations = React.useMemo(() => {
if (!templateProfile || !combinedProfile) return new Set<number>(); if (!templateProfile || !combinedProfile) return new Set<number>();
const deviatingDays = new Set<number>(); const deviatingDays = new Set<number>();
const simDays = parseInt(simulationDays, 10) || 3; const simDays = parseInt(simulationDays, 10) || 3;
// Check each day starting from day 2 (day 1 is always template) // Check each day starting from day 2 (day 1 is always regular plan)
for (let day = 2; day <= simDays; day++) { for (let day = 2; day <= simDays; day++) {
const dayStartHour = (day - 1) * 24; const dayStartHour = (day - 1) * 24;
const dayEndHour = day * 24; const dayEndHour = day * 24;
@@ -302,11 +349,6 @@ const chartDomain = React.useMemo(() => {
return Array.from(dataMap.values()).sort((a, b) => a.timeHours - b.timeHours); return Array.from(dataMap.values()).sort((a, b) => a.timeHours - b.timeHours);
}, [combinedProfile, templateProfile, daysWithDeviations]); }, [combinedProfile, templateProfile, daysWithDeviations]);
// Calculate chart width for scrollable area
const chartWidth = simDays <= dispDays
? scrollableWidth
: Math.ceil((scrollableWidth / dispDays) * simDays);
// Render legend with tooltips for full names (custom legend renderer) // Render legend with tooltips for full names (custom legend renderer)
const renderLegend = React.useCallback((props: any) => { const renderLegend = React.useCallback((props: any) => {
const { payload } = props; const { payload } = props;
@@ -343,6 +385,7 @@ const chartDomain = React.useMemo(() => {
); );
}, [seriesLabels]); }, [seriesLabels]);
// Render the chart
return ( return (
<div ref={containerRef} className="flex-grow w-full flex flex-col overflow-y-hidden"> <div ref={containerRef} className="flex-grow w-full flex flex-col overflow-y-hidden">
{/* Fixed Legend at top */} {/* Fixed Legend at top */}
@@ -405,55 +448,29 @@ const chartDomain = React.useMemo(() => {
syncId="medPlanChart" syncId="medPlanChart"
> >
{/** Custom tick renderer to italicize 'Noon' only in 12h mode */ } {/** Custom tick renderer to italicize 'Noon' only in 12h mode */ }
{(() => { <XAxis
const CustomTick = (props: any) => {
const { x, y, payload } = props;
const h = payload.value as number;
let label: string;
if (showDayTimeOnXAxis === '24h') {
label = `${h % 24}${t('unitHour')}`;
} else if (showDayTimeOnXAxis === '12h') {
const hour12 = h % 24;
if (hour12 === 12) {
label = t('tickNoon');
return (
<text x={x} y={y + 12} textAnchor="middle" fontStyle="italic" fill="#666">
{label}
</text>
);
}
const displayHour = hour12 === 0 ? 12 : hour12 > 12 ? hour12 - 12 : hour12;
const period = hour12 < 12 ? 'a' : 'p';
label = `${displayHour}${period}`;
} else {
label = `${h}`;
}
return (
<text x={x} y={y + 12} textAnchor="middle" fill="#666">
{label}
</text>
);
};
return <XAxis
xAxisId="hours" xAxisId="hours"
//label={{ value: showDayTimeOnXAxis === 'continuous' ? t('axisLabelHours') : t('axisLabelTimeOfDay'), position: 'insideBottom', offset: -10, style: { fontStyle: 'italic', color: '#666' } }} //label={{ value: showDayTimeOnXAxis === 'continuous' ? t('axisLabelHours') : t('axisLabelTimeOfDay'), position: 'insideBottom', offset: -10, style: { fontStyle: 'italic', color: '#666' } }}
dataKey="timeHours" dataKey="timeHours"
type="number" type="number"
domain={[0, totalHours]} domain={[0, totalHours]}
ticks={chartTicks} tick={<XAxisTick />}
tickCount={chartTicks.length} ticks={xAxisTicks}
interval={0} tickCount={xAxisTicks.length}
tick={<CustomTick />} //tickCount={200}
/>; //interval={1}
})()} allowDecimals={false}
allowDataOverflow={false}
/>
<YAxis <YAxis
yAxisId="concentration" yAxisId="concentration"
// FIXME // FIXME
//label={{ value: t('axisLabelConcentration'), angle: -90, position: 'insideLeft', style: { fontStyle: 'italic', color: '#666' } }} //label={{ value: t('axisLabelConcentration'), angle: -90, position: 'insideLeft', style: { fontStyle: 'italic', color: '#666' } }}
domain={chartDomain as any} domain={yAxisDomain as any}
allowDecimals={false}
tickCount={20} tickCount={20}
interval={1}
allowDecimals={false}
allowDataOverflow={false}
/> />
<RechartsTooltip <RechartsTooltip
content={({ active, payload, label }) => { content={({ active, payload, label }) => {

View File

@@ -34,7 +34,7 @@ const CollapsibleCardHeader: React.FC<CollapsibleCardHeaderProps> = ({
return ( return (
<CardHeader className={cn('pb-3', className)}> <CardHeader className={cn('pb-3', className)}>
<div className="flex items-center justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap flex-1"> <div className="flex items-center gap-2 flex-wrap flex-1">
<button <button
type="button" type="button"
@@ -48,7 +48,7 @@ const CollapsibleCardHeader: React.FC<CollapsibleCardHeaderProps> = ({
</CardTitle> </CardTitle>
{isCollapsed ? <ChevronDown className="h-5 w-5 flex-shrink-0" /> : <ChevronUp className="h-5 w-5 flex-shrink-0" />} {isCollapsed ? <ChevronDown className="h-5 w-5 flex-shrink-0" /> : <ChevronUp className="h-5 w-5 flex-shrink-0" />}
</button> </button>
{children} {children && <div className="flex items-center gap-2 flex-nowrap">{children}</div>}
</div> </div>
{rightSection && <div className="flex items-center gap-2">{rightSection}</div>} {rightSection && <div className="flex items-center gap-2">{rightSection}</div>}
</div> </div>

View File

@@ -31,6 +31,7 @@ interface NumericInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
required?: boolean required?: boolean
errorMessage?: string errorMessage?: string
warningMessage?: string warningMessage?: string
inputWidth?: string // Custom width for the input field (e.g., 'w-16', 'w-20')
} }
const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>( const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
@@ -49,6 +50,7 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
required = false, required = false,
errorMessage = 'Time is required', errorMessage = 'Time is required',
warningMessage, warningMessage,
inputWidth = 'w-20', // Default width
className, className,
...props ...props
}, ref) => { }, ref) => {
@@ -74,7 +76,7 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
}, [isInvalid, touched]) }, [isInvalid, touched])
// Determine decimal places based on increment // Determine decimal places based on increment
const getDecimalPlaces = () => { const getDecimalPlaces = () => {
const inc = String(increment || '1') const inc = String(increment || '1').replace(',', '.')
const decimalIndex = inc.indexOf('.') const decimalIndex = inc.indexOf('.')
if (decimalIndex === -1) return 0 if (decimalIndex === -1) return 0
return inc.length - decimalIndex - 1 return inc.length - decimalIndex - 1
@@ -97,7 +99,17 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
numValue = 0 numValue = 0
} }
numValue += direction * numIncrement // Snap to nearest increment first, then move one increment in the desired direction
if (direction > 0) {
// For increment: round up to next increment value, ensuring at least one increment is added
const snapped = Math.ceil(numValue / numIncrement) * numIncrement
numValue = snapped > numValue ? snapped : snapped + numIncrement
} else {
// For decrement: round down to previous increment value, ensuring at least one increment is subtracted
const snapped = Math.floor(numValue / numIncrement) * numIncrement
numValue = snapped < numValue ? snapped : snapped - numIncrement
}
numValue = Math.max(min, numValue) numValue = Math.max(min, numValue)
numValue = Math.min(max, numValue) numValue = Math.min(max, numValue)
onChange(formatValue(numValue)) onChange(formatValue(numValue))
@@ -111,7 +123,10 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
} }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value let val = e.target.value
// Replace comma with period to support European decimal separator
val = val.replace(',', '.')
// Allow any valid numeric input during typing (including partial values like "1", "12.", etc.)
if (val === '' || /^-?\d*\.?\d*$/.test(val)) { if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
onChange(val) onChange(val)
} }
@@ -131,7 +146,11 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
} }
if (inputValue !== '' && !isNaN(Number(inputValue))) { if (inputValue !== '' && !isNaN(Number(inputValue))) {
onChange(formatValue(inputValue)) let numValue = Number(inputValue)
// Enforce min/max constraints
numValue = Math.max(min, numValue)
numValue = Math.min(max, numValue)
onChange(formatValue(numValue))
} }
} }
@@ -176,7 +195,7 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
onFocus={handleFocus} onFocus={handleFocus}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={cn( className={cn(
"w-20 h-9 z-20", inputWidth, "h-9 z-10",
"rounded-none", "rounded-none",
getAlignmentClass(), getAlignmentClass(),
hasError && "border-destructive focus-visible:ring-destructive", hasError && "border-destructive focus-visible:ring-destructive",
@@ -218,12 +237,12 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
</div> </div>
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>} {unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
{hasError && isFocused && errorMessage && ( {hasError && isFocused && errorMessage && (
<div className="absolute top-full left-0 mt-1 z-25 w-64 bg-destructive text-destructive-foreground text-xs p-2 rounded-md shadow-lg"> <div className="absolute top-full left-0 mt-1 z-20 w-64 bg-destructive text-destructive-foreground text-xs p-2 rounded-md shadow-lg">
{errorMessage} {errorMessage}
</div> </div>
)} )}
{hasWarning && isFocused && warningMessage && ( {hasWarning && isFocused && warningMessage && (
<div className="absolute top-full left-0 mt-1 z-25 w-48 bg-yellow-500 text-white text-xs p-2 rounded-md shadow-lg"> <div className="absolute top-full left-0 mt-1 z-20 w-48 bg-yellow-500 text-white text-xs p-2 rounded-md shadow-lg">
{warningMessage} {warningMessage}
</div> </div>
)} )}

View File

@@ -51,6 +51,9 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
const [isFocused, setIsFocused] = React.useState(false) const [isFocused, setIsFocused] = React.useState(false)
const containerRef = React.useRef<HTMLDivElement>(null) const containerRef = React.useRef<HTMLDivElement>(null)
// Store original value when opening picker (for cancel/revert)
const [originalValue, setOriginalValue] = React.useState<string>('')
// Current committed value parsed from prop // Current committed value parsed from prop
const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number) const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number)
@@ -128,26 +131,43 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
const handlePickerOpen = (open: boolean) => { const handlePickerOpen = (open: boolean) => {
setIsPickerOpen(open) setIsPickerOpen(open)
if (open) { if (open) {
// Reset staging when opening picker // Save original value for cancel/revert and reset staging
setOriginalValue(value)
setStagedHour(null) setStagedHour(null)
setStagedMinute(null) setStagedMinute(null)
} else if (!open && originalValue) {
// Closing without explicit Apply - revert to original value
onChange(originalValue)
setOriginalValue('')
} }
} }
const handleHourClick = (hour: number) => { const handleHourClick = (hour: number) => {
setStagedHour(hour) setStagedHour(hour)
// Update simulation immediately with new hour (keeping current or staged minute)
const finalMinute = stagedMinute !== null ? stagedMinute : pickerMinutes
const formattedTime = `${String(hour).padStart(2, '0')}:${String(finalMinute).padStart(2, '0')}`
onChange(formattedTime)
} }
const handleMinuteClick = (minute: number) => { const handleMinuteClick = (minute: number) => {
setStagedMinute(minute) setStagedMinute(minute)
// Update simulation immediately with new minute (keeping current or staged hour)
const finalHour = stagedHour !== null ? stagedHour : pickerHours
const formattedTime = `${String(finalHour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
onChange(formattedTime)
} }
const handleApply = () => { const handleApply = () => {
// Use staged values if selected, otherwise keep current values // Commit the current value (already updated in real-time) and close
const finalHour = stagedHour !== null ? stagedHour : pickerHours setOriginalValue('') // Clear original so revert doesn't happen on close
const finalMinute = stagedMinute !== null ? stagedMinute : pickerMinutes setIsPickerOpen(false)
const formattedTime = `${String(finalHour).padStart(2, '0')}:${String(finalMinute).padStart(2, '0')}` }
onChange(formattedTime)
const handleCancel = () => {
// Revert to original value
onChange(originalValue)
setOriginalValue('')
setIsPickerOpen(false) setIsPickerOpen(false)
} }
@@ -245,7 +265,15 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={handleCancel}
>
{t('timePickerCancel')}
</Button>
<Button <Button
type="button" type="button"
size="sm" size="sm"

View File

@@ -54,6 +54,54 @@ export const useAppState = () => {
} }
} }
// Validate numeric fields and replace empty/invalid values with defaults
const validateNumericField = (value: any, defaultValue: any): any => {
if (value === '' || value === null || value === undefined || isNaN(Number(value))) {
return defaultValue;
}
return value;
};
// Validate basic pkParams
if (migratedPkParams.basic) {
migratedPkParams.basic.eliminationHalfLife = validateNumericField(
migratedPkParams.basic.eliminationHalfLife,
defaults.pkParams.basic.eliminationHalfLife
);
migratedPkParams.basic.bodyWeight = validateNumericField(
migratedPkParams.basic.bodyWeight,
defaults.pkParams.basic.bodyWeight
);
}
// Validate advanced pkParams
if (migratedPkParams.advanced) {
migratedPkParams.advanced.conversionEfficiency = validateNumericField(
migratedPkParams.advanced.conversionEfficiency,
defaults.pkParams.advanced.conversionEfficiency
);
migratedPkParams.advanced.bioavailability = validateNumericField(
migratedPkParams.advanced.bioavailability,
defaults.pkParams.advanced.bioavailability
);
migratedPkParams.advanced.customVolumeOfDistribution = validateNumericField(
migratedPkParams.advanced.customVolumeOfDistribution,
defaults.pkParams.advanced.customVolumeOfDistribution
);
migratedPkParams.advanced.absorptionDelay = validateNumericField(
migratedPkParams.advanced.absorptionDelay,
defaults.pkParams.advanced.absorptionDelay
);
migratedPkParams.advanced.absorptionRateConstant = validateNumericField(
migratedPkParams.advanced.absorptionRateConstant,
defaults.pkParams.advanced.absorptionRateConstant
);
migratedPkParams.advanced.mealDelayFactor = validateNumericField(
migratedPkParams.advanced.mealDelayFactor,
defaults.pkParams.advanced.mealDelayFactor
);
}
setAppState({ setAppState({
...defaults, ...defaults,
...parsedState, ...parsedState,

View File

@@ -312,6 +312,7 @@ export const de = {
timePickerHour: "Stunde", timePickerHour: "Stunde",
timePickerMinute: "Minute", timePickerMinute: "Minute",
timePickerApply: "Übernehmen", timePickerApply: "Übernehmen",
timePickerCancel: "Abbrechen",
// Sorting // Sorting
sortByTime: "Nach Zeit sortieren", sortByTime: "Nach Zeit sortieren",

View File

@@ -285,6 +285,7 @@ export const en = {
timePickerHour: "Hour", timePickerHour: "Hour",
timePickerMinute: "Minute", timePickerMinute: "Minute",
timePickerApply: "Apply", timePickerApply: "Apply",
timePickerCancel: "Cancel",
// Sorting // Sorting
sortByTime: "Sort by time", sortByTime: "Sort by time",