From f4260061f5836dc87734025b600c994874f8457b Mon Sep 17 00:00:00 2001 From: Andreas Weyer Date: Mon, 2 Feb 2026 17:35:11 +0000 Subject: [PATCH] Update various improvements and minor changes --- src/App.tsx | 5 +- src/components/day-schedule.tsx | 88 +++--- src/components/settings.tsx | 16 +- src/components/simulation-chart.tsx | 251 ++++++++++-------- src/components/ui/collapsible-card-header.tsx | 4 +- src/components/ui/form-numeric-input.tsx | 33 ++- src/components/ui/form-time-input.tsx | 42 ++- src/hooks/useAppState.ts | 48 ++++ src/locales/de.ts | 1 + src/locales/en.ts | 1 + 10 files changed, 308 insertions(+), 181 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0b14cf5..1cc4aa3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -165,7 +165,10 @@ const MedPlanAssistant = () => { onImportDays={(importedDays: any) => updateState('days', importedDays)} /> -
+
diff --git a/src/components/day-schedule.tsx b/src/components/day-schedule.tsx index cb5fe9c..4228877 100644 --- a/src/components/day-schedule.tsx +++ b/src/components/day-schedule.tsx @@ -201,7 +201,7 @@ const DaySchedule: React.FC = ({ {!collapsedDays.has(day.id) && ( {/* Dose table header */} -
+
{t('time')} @@ -229,10 +229,10 @@ const DaySchedule: React.FC = ({
{t('ldx')} (mg)
-
+ {/*
-
-
-
+
*/} +
-
{/* Dose rows */} @@ -245,43 +245,49 @@ const DaySchedule: React.FC = ({ const isZeroDose = dose.ldx === '0' || dose.ldx === '0.0'; return ( -
- onUpdateDose(day.id, dose.id, 'time', value)} - required={true} - warning={hasDuplicateTime} - errorMessage={t('errorTimeRequired')} - warningMessage={t('warningDuplicateTime')} - /> - onUpdateDose(day.id, dose.id, 'ldx', value)} - increment={doseIncrement} - min={0} - unit="mg" - required={true} - warning={isZeroDose} - errorMessage={t('errorNumberRequired')} - warningMessage={t('warningZeroDose')} - /> - onUpdateDoseField(day.id, dose.id, 'isFed', !dose.isFed)} - icon={} - tooltip={dose.isFed ? t('doseWithFood') : t('doseFasted')} - size="sm" - variant={dose.isFed ? "default" : "outline"} - className={`h-9 w-9 p-0 ${dose.isFed ? 'bg-orange-500 hover:bg-orange-600' : ''}`} - /> - onRemoveDose(day.id, dose.id)} - icon={} - tooltip={t('removeDose')} - size="sm" - variant="outline" - disabled={day.isTemplate && day.doses.length === 1} - className="h-9 w-9 p-0 border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:border-muted" - /> +
+
+ onUpdateDose(day.id, dose.id, 'time', value)} + required={true} + warning={hasDuplicateTime} + errorMessage={t('errorTimeRequired')} + warningMessage={t('warningDuplicateTime')} + /> + onUpdateDose(day.id, dose.id, 'ldx', value)} + increment={doseIncrement} + min={0} + max={200} + //unit="mg" + required={true} + warning={isZeroDose} + errorMessage={t('errorNumberRequired')} + warningMessage={t('warningZeroDose')} + inputWidth="w-[72px]" + /> +
+ onUpdateDoseField(day.id, dose.id, 'isFed', !dose.isFed)} + icon={} + tooltip={dose.isFed ? t('doseWithFood') : t('doseFasted')} + size="sm" + variant={dose.isFed ? "default" : "outline"} + className={`h-9 w-9 p-0 ${dose.isFed ? 'bg-orange-500 hover:bg-orange-600' : ''}`} + /> + onRemoveDose(day.id, dose.id)} + icon={} + tooltip={t('removeDose')} + size="sm" + variant="outline" + disabled={day.isTemplate && day.doses.length === 1} + className="h-9 w-9 p-0 border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:border-muted" + /> +
+
); })} diff --git a/src/components/settings.tsx b/src/components/settings.tsx index 90086dc..e108cc3 100644 --- a/src/components/settings.tsx +++ b/src/components/settings.tsx @@ -403,6 +403,7 @@ const Settings = ({ onChange={val => onUpdateTherapeuticRange('min', val)} increment={0.5} min={0} + max={500} placeholder={t('min')} required={true} error={!!therapeuticRangeError || !therapeuticRange.min} @@ -414,6 +415,7 @@ const Settings = ({ onChange={val => onUpdateTherapeuticRange('max', val)} increment={0.5} min={0} + max={500} placeholder={t('max')} unit="ng/ml" required={true} @@ -485,6 +487,7 @@ const Settings = ({ onChange={val => onUpdateUiSetting('yAxisMin', val)} increment={1} min={0} + max={500} placeholder={t('auto')} allowEmpty={true} clearButton={true} @@ -497,6 +500,7 @@ const Settings = ({ onChange={val => onUpdateUiSetting('yAxisMax', val)} increment={1} min={0} + max={500} placeholder={t('auto')} unit="ng/ml" allowEmpty={true} @@ -688,7 +692,7 @@ const Settings = ({ onChange={val => onUpdatePkParams('damph', { ...pkParams.damph, halfLife: val })} increment={0.5} min={5} - max={34} + max={50} unit="h" required={true} warning={eliminationWarning && !eliminationExtreme} @@ -726,7 +730,7 @@ const Settings = ({ onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, halfLife: val })} increment={0.1} min={0.5} - max={2} + max={5} unit="h" required={true} warning={conversionWarning} @@ -760,7 +764,7 @@ const Settings = ({ onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, absorptionHalfLife: val })} increment={0.1} min={0.5} - max={2} + max={5} unit="h" required={true} warning={absorptionWarning} @@ -836,7 +840,7 @@ const Settings = ({ onChange={val => updateAdvanced('standardVd', 'customValue', val)} increment={10} min={50} - max={800} + max={2000} unit="L" required={true} /> @@ -905,7 +909,7 @@ const Settings = ({ onChange={val => updateAdvanced('weightBasedVd', 'bodyWeight', val)} increment={1} min={20} - max={150} + max={300} unit={t('bodyWeightUnit')} required={true} /> @@ -943,7 +947,7 @@ const Settings = ({ onChange={val => updateAdvanced('foodEffect', 'tmaxDelay', val)} increment={0.1} min={0} - max={2} + max={5} unit={t('tmaxDelayUnit')} required={true} /> diff --git a/src/components/simulation-chart.tsx b/src/components/simulation-chart.tsx index 5476785..ba52880 100644 --- a/src/components/simulation-chart.tsx +++ b/src/components/simulation-chart.tsx @@ -67,6 +67,7 @@ const SimulationChart = ({ }: any) => { const totalHours = (parseInt(simulationDays, 10) || 3) * 24; const dispDays = parseInt(displayedDays, 10) || 2; + const simDays = parseInt(simulationDays, 10) || 3; // Calculate chart dimensions const [containerWidth, setContainerWidth] = React.useState(1000); @@ -84,9 +85,19 @@ const SimulationChart = ({ 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 const isCompactLabels = containerWidth < 640; // tweakable threshold for mobile + // Precompute series labels with translations const seriesLabels = React.useMemo>(() => { const damphFull = t('dAmphetamine'); const damphShort = t('dAmphetamineShort', { defaultValue: damphFull }); @@ -121,15 +132,10 @@ const SimulationChart = ({ }; }, [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 - // Aim for ~46px per label to avoid overlaps on narrow screens 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 intervals = [1, 2, 3, 4, 6, 8, 12, 24]; @@ -146,8 +152,8 @@ const SimulationChart = ({ return selected ?? 24; }, [dispDays, scrollableWidth]); - // Generate ticks for continuous time axis - const chartTicks = React.useMemo(() => { + // Generate x-axis ticks for continuous time axis + const xAxisTicks = React.useMemo(() => { const ticks = []; for (let i = 0; i <= totalHours; i += xTickInterval) { ticks.push(i); @@ -155,79 +161,120 @@ const SimulationChart = ({ return ticks; }, [totalHours, xTickInterval]); -const chartDomain = React.useMemo(() => { - const numMin = parseFloat(yAxisMin); - const numMax = parseFloat(yAxisMax); - - // Calculate actual data range if auto is needed - let dataMin = Infinity; - let dataMax = -Infinity; - - if (isNaN(numMin) || isNaN(numMax)) { - // Scan through combined profile data to find actual min/max - combinedProfile?.forEach((point: any) => { - if (chartView === 'damph' || chartView === 'both') { - dataMin = Math.min(dataMin, point.damph); - dataMax = Math.max(dataMax, point.damph); + // 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 ( + + {label} + + ); } - if (chartView === 'ldx' || chartView === 'both') { - dataMin = Math.min(dataMin, point.ldx); - dataMax = Math.max(dataMax, point.ldx); + const displayHour = hour12 === 0 ? 12 : hour12 > 12 ? hour12 - 12 : hour12; + const period = hour12 < 12 ? 'a' : 'p'; + label = `${displayHour}${period}`; + } else { + label = `${h}`; + } + return ( + + {label} + + ); + }; + + // Calculate Y-axis domain based on data and user settings + const yAxisDomain = React.useMemo(() => { + const numMin = parseFloat(yAxisMin); + const numMax = parseFloat(yAxisMax); + + // Calculate actual data range if auto is needed + let dataMin = Infinity; + let dataMax = -Infinity; + + if (isNaN(numMin) || isNaN(numMax)) { + // Scan through combined profile data to find actual min/max + combinedProfile?.forEach((point: any) => { + if (chartView === 'damph' || chartView === 'both') { + dataMin = Math.min(dataMin, point.damph); + dataMax = Math.max(dataMax, point.damph); + } + if (chartView === 'ldx' || chartView === 'both') { + dataMin = Math.min(dataMin, point.ldx); + dataMax = Math.max(dataMax, point.ldx); + } + }); + + // Also check template profile if shown + templateProfile?.forEach((point: any) => { + if (chartView === 'damph' || chartView === 'both') { + dataMin = Math.min(dataMin, point.damph); + dataMax = Math.max(dataMax, point.damph); + } + if (chartView === 'ldx' || chartView === 'both') { + dataMin = Math.min(dataMin, point.ldx); + dataMax = Math.max(dataMax, point.ldx); + } + }); + } + + // Calculate final domain min + let domainMin: number; + if (!isNaN(numMin)) { // max value provided via settings + // User set yAxisMin explicitly + domainMin = numMin; + } else if (dataMin !== Infinity) { // data exists + // Auto mode: add 10% padding below so the line is not flush with x-axis + const range = dataMax - dataMin; + const padding = range * 0.1; + domainMin = Math.max(0, dataMin - padding); + } else { // no data + domainMin = 0; + } + + // Calculate final domain max + let domainMax: number; + if (!isNaN(numMax)) { // max value provided via settings + if (dataMax !== -Infinity) { + // User set yAxisMax explicitly + // Add padding to dataMax and use the higher of manual or (dataMax + padding) + const range = dataMax - dataMin; + 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; + } else { // no data + domainMax = 100; + } - // Also check template profile if shown - templateProfile?.forEach((point: any) => { - if (chartView === 'damph' || chartView === 'both') { - dataMin = Math.min(dataMin, point.damph); - dataMax = Math.max(dataMax, point.damph); - } - if (chartView === 'ldx' || chartView === 'both') { - dataMin = Math.min(dataMin, point.ldx); - dataMax = Math.max(dataMax, point.ldx); - } - }); - } + return [domainMin, domainMax]; + }, [yAxisMin, yAxisMax, combinedProfile, templateProfile, chartView]); - // Calculate final domain min - let domainMin: number; - if (!isNaN(numMin)) { // max value provided via settings - // User set yAxisMin explicitly - domainMin = numMin; - } else if (dataMin !== Infinity) { // data exists - // Auto mode: add 5% padding below so the line is not flush with x-axis - const range = dataMax - dataMin; - const padding = range * 0.05; - domainMin = Math.max(0, dataMin - padding); - } else { // no data - domainMin = 0; - } - - // Calculate final domain max - let domainMax: number; - if (!isNaN(numMax)) { // max value provided via settings - // User set yAxisMax explicitly - use it as-is without padding - domainMax = numMax; - } else if (dataMax !== -Infinity) { // data exists - // Auto mode: add 5% padding above - const range = dataMax - dataMin; - const padding = range * 0.05; - domainMax = dataMax + padding; - } else { // no data - domainMax = 100; - } - - return [domainMin, domainMax]; -}, [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(() => { if (!templateProfile || !combinedProfile) return new Set(); const deviatingDays = new Set(); 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++) { const dayStartHour = (day - 1) * 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); }, [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) const renderLegend = React.useCallback((props: any) => { const { payload } = props; @@ -343,6 +385,7 @@ const chartDomain = React.useMemo(() => { ); }, [seriesLabels]); + // Render the chart return (
{/* Fixed Legend at top */} @@ -404,56 +447,30 @@ const chartDomain = React.useMemo(() => { margin={{ top: 0, right: 20, left: 0, bottom: 5 }} syncId="medPlanChart" > - {/** Custom tick renderer to italicize 'Noon' only in 12h mode */} - {(() => { - 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 ( - - {label} - - ); - } - const displayHour = hour12 === 0 ? 12 : hour12 > 12 ? hour12 - 12 : hour12; - const period = hour12 < 12 ? 'a' : 'p'; - label = `${displayHour}${period}`; - } else { - label = `${h}`; - } - return ( - - {label} - - ); - }; - return } - />; - })()} - + tick={} + ticks={xAxisTicks} + tickCount={xAxisTicks.length} + //tickCount={200} + //interval={1} + allowDecimals={false} + allowDataOverflow={false} + /> { diff --git a/src/components/ui/collapsible-card-header.tsx b/src/components/ui/collapsible-card-header.tsx index 31c1590..c8ef146 100644 --- a/src/components/ui/collapsible-card-header.tsx +++ b/src/components/ui/collapsible-card-header.tsx @@ -34,7 +34,7 @@ const CollapsibleCardHeader: React.FC = ({ return ( -
+
- {children} + {children &&
{children}
}
{rightSection &&
{rightSection}
}
diff --git a/src/components/ui/form-numeric-input.tsx b/src/components/ui/form-numeric-input.tsx index 714e27b..1359d18 100644 --- a/src/components/ui/form-numeric-input.tsx +++ b/src/components/ui/form-numeric-input.tsx @@ -31,6 +31,7 @@ interface NumericInputProps extends Omit( @@ -49,6 +50,7 @@ const FormNumericInput = React.forwardRef( required = false, errorMessage = 'Time is required', warningMessage, + inputWidth = 'w-20', // Default width className, ...props }, ref) => { @@ -74,7 +76,7 @@ const FormNumericInput = React.forwardRef( }, [isInvalid, touched]) // Determine decimal places based on increment const getDecimalPlaces = () => { - const inc = String(increment || '1') + const inc = String(increment || '1').replace(',', '.') const decimalIndex = inc.indexOf('.') if (decimalIndex === -1) return 0 return inc.length - decimalIndex - 1 @@ -97,7 +99,17 @@ const FormNumericInput = React.forwardRef( 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.min(max, numValue) onChange(formatValue(numValue)) @@ -111,7 +123,10 @@ const FormNumericInput = React.forwardRef( } const handleChange = (e: React.ChangeEvent) => { - 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)) { onChange(val) } @@ -131,7 +146,11 @@ const FormNumericInput = React.forwardRef( } 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( onFocus={handleFocus} onKeyDown={handleKeyDown} className={cn( - "w-20 h-9 z-20", + inputWidth, "h-9 z-10", "rounded-none", getAlignmentClass(), hasError && "border-destructive focus-visible:ring-destructive", @@ -218,12 +237,12 @@ const FormNumericInput = React.forwardRef(
{unit && {unit}} {hasError && isFocused && errorMessage && ( -
+
{errorMessage}
)} {hasWarning && isFocused && warningMessage && ( -
+
{warningMessage}
)} diff --git a/src/components/ui/form-time-input.tsx b/src/components/ui/form-time-input.tsx index 2841364..2e2d3dd 100644 --- a/src/components/ui/form-time-input.tsx +++ b/src/components/ui/form-time-input.tsx @@ -51,6 +51,9 @@ const FormTimeInput = React.forwardRef( const [isFocused, setIsFocused] = React.useState(false) const containerRef = React.useRef(null) + // Store original value when opening picker (for cancel/revert) + const [originalValue, setOriginalValue] = React.useState('') + // Current committed value parsed from prop const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number) @@ -128,26 +131,43 @@ const FormTimeInput = React.forwardRef( const handlePickerOpen = (open: boolean) => { setIsPickerOpen(open) if (open) { - // Reset staging when opening picker + // Save original value for cancel/revert and reset staging + setOriginalValue(value) setStagedHour(null) setStagedMinute(null) + } else if (!open && originalValue) { + // Closing without explicit Apply - revert to original value + onChange(originalValue) + setOriginalValue('') } } const handleHourClick = (hour: number) => { 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) => { 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 = () => { - // Use staged values if selected, otherwise keep current values - const finalHour = stagedHour !== null ? stagedHour : pickerHours - const finalMinute = stagedMinute !== null ? stagedMinute : pickerMinutes - const formattedTime = `${String(finalHour).padStart(2, '0')}:${String(finalMinute).padStart(2, '0')}` - onChange(formattedTime) + // Commit the current value (already updated in real-time) and close + setOriginalValue('') // Clear original so revert doesn't happen on close + setIsPickerOpen(false) + } + + const handleCancel = () => { + // Revert to original value + onChange(originalValue) + setOriginalValue('') setIsPickerOpen(false) } @@ -245,7 +265,15 @@ const FormTimeInput = React.forwardRef(
-
+
+