/** * Settings Component * * Provides configuration for pharmacokinetic parameters (half-lives, * absorption rates) and UI settings (chart view, therapeutic ranges, * x-axis format). Includes data management (import/export/reset). * * @author Andreas Weyer * @license MIT */ import React from 'react'; import { Card, CardContent } from './ui/card'; import { Label } from './ui/label'; import { Switch } from './ui/switch'; import { Separator } from './ui/separator'; import { Button } from './ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { FormNumericInput } from './ui/form-numeric-input'; import CollapsibleCardHeader from './ui/collapsible-card-header'; import { Info } from 'lucide-react'; import { getDefaultState } from '../constants/defaults'; /** * Helper function to create translation interpolation values for defaults. * Derives default values dynamically from hardcoded defaults. */ const getDefaultsForTranslation = (pkParams: any, therapeuticRange: any, uiSettings: any) => { const defaults = getDefaultState(); return { // UI Settings simulationDays: defaults.uiSettings.simulationDays, displayedDays: defaults.uiSettings.displayedDays, yAxisMin: defaults.uiSettings.yAxisMin, yAxisMax: defaults.uiSettings.yAxisMax, therapeuticRangeMin: defaults.therapeuticRange.min, therapeuticRangeMax: defaults.therapeuticRange.max, // PK Parameters damphHalfLife: defaults.pkParams.damph.halfLife, ldxHalfLife: defaults.pkParams.ldx.halfLife, ldxAbsorptionHalfLife: defaults.pkParams.ldx.absorptionHalfLife, // Advanced Settings standardVdValue: defaults.pkParams.advanced.standardVd?.preset === 'adult' ? '377' : defaults.pkParams.advanced.standardVd?.preset === 'child' ? '175' : defaults.pkParams.advanced.standardVd?.customValue || '377', standardVdPreset: defaults.pkParams.advanced.standardVd?.preset || 'adult', customVdValue: defaults.pkParams.advanced.standardVd.customValue, bodyWeight: defaults.pkParams.advanced.standardVd.bodyWeight, tmaxDelay: defaults.pkParams.advanced.foodEffect.tmaxDelay, fOral: defaults.pkParams.advanced.fOral, fOralPercent: String((parseFloat(defaults.pkParams.advanced.fOral) * 100).toFixed(1)), steadyStateDays: defaults.pkParams.advanced.steadyStateDays, }; }; /** * Helper function to interpolate translation strings with default values. * @example t('simulationDurationTooltip', defaultsForT) → "...Default: 5 days." */ const tWithDefaults = (translationFn: any, key: string, defaults: Record) => { const translated = translationFn(key); let result = translated; // Replace all {{placeholder}} patterns with values from defaults object Object.entries(defaults).forEach(([placeholder, value]) => { result = result.replace(new RegExp(`{{${placeholder}}}`, 'g'), String(value)); }); return result; }; /** * Helper function to render tooltip content with inline source links. * Parses [link text](url) markdown-style syntax and renders as clickable links. * @example "See [this study](https://example.com)" → clickable link within tooltip */ const renderTooltipWithLinks = (text: string): React.ReactNode => { const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; const parts: React.ReactNode[] = []; let lastIndex = 0; let match; while ((match = linkRegex.exec(text)) !== null) { // Add text before link if (match.index > lastIndex) { parts.push(text.substring(lastIndex, match.index)); } // Add link parts.push( {match[1]} ); lastIndex = linkRegex.lastIndex; } // Add remaining text if (lastIndex < text.length) { parts.push(text.substring(lastIndex)); } return parts.length > 0 ? parts : text; }; const Settings = ({ pkParams, therapeuticRange, uiSettings, days, doseIncrement, onUpdatePkParams, onUpdateTherapeuticRange, onUpdateUiSetting, onImportDays, onOpenDataManagement, t }: any) => { const { showDayTimeOnXAxis, yAxisMin, yAxisMax, showTemplateDay, simulationDays, displayedDays } = uiSettings; const showDayReferenceLines = (uiSettings as any).showDayReferenceLines ?? true; const showTherapeuticRange = (uiSettings as any).showTherapeuticRange ?? true; const steadyStateDaysEnabled = (uiSettings as any).steadyStateDaysEnabled ?? true; const [isDiagramExpanded, setIsDiagramExpanded] = React.useState(true); const [isSimulationExpanded, setIsSimulationExpanded] = React.useState(true); const [isPharmacokineticExpanded, setIsPharmacokineticExpanded] = React.useState(true); const [isAdvancedExpanded, setIsAdvancedExpanded] = React.useState(false); // Track which tooltip is currently open (for mobile touch interaction) const [openTooltipId, setOpenTooltipId] = React.useState(null); // Validation state for range inputs const [therapeuticRangeError, setTherapeuticRangeError] = React.useState(''); const [yAxisRangeError, setYAxisRangeError] = React.useState(''); // Track window width for responsive tooltip positioning const [isNarrowScreen, setIsNarrowScreen] = React.useState( typeof window !== 'undefined' ? window.innerWidth < 640 : false ); // Update narrow screen state on window resize React.useEffect(() => { const handleResize = () => { setIsNarrowScreen(window.innerWidth < 640); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); // Determine tooltip side based on screen width const tooltipSide = isNarrowScreen ? 'top' : 'right'; // Load and persist settings card expansion states React.useEffect(() => { const savedStates = localStorage.getItem('settingsCardStates_v1'); if (savedStates) { try { const states = JSON.parse(savedStates); if (states.diagram !== undefined) setIsDiagramExpanded(states.diagram); if (states.simulation !== undefined) setIsSimulationExpanded(states.simulation); if (states.pharmacokinetic !== undefined) setIsPharmacokineticExpanded(states.pharmacokinetic); if (states.advanced !== undefined) setIsAdvancedExpanded(states.advanced); } catch (e) { console.warn('Failed to load settings card states:', e); } } }, []); // Close tooltip when clicking outside React.useEffect(() => { if (!openTooltipId) return; const handleClickOutside = (e: MouseEvent | TouchEvent) => { const target = e.target as HTMLElement; // Check if click is outside tooltip content and button if (!target.closest('[role="tooltip"]') && !target.closest('button[aria-label*="Tooltip"]')) { setOpenTooltipId(null); } }; // Small delay to prevent immediate closure setTimeout(() => { document.addEventListener('mousedown', handleClickOutside); document.addEventListener('touchstart', handleClickOutside); }, 10); return () => { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('touchstart', handleClickOutside); }; }, [openTooltipId]); // Helper to toggle tooltip (for mobile click interaction) const handleTooltipToggle = (tooltipId: string) => (e: React.MouseEvent | React.TouchEvent) => { e.preventDefault(); e.stopPropagation(); setOpenTooltipId(current => current === tooltipId ? null : tooltipId); }; const updateDiagramExpanded = (value: boolean) => { setIsDiagramExpanded(value); saveCardStates({ diagram: value, simulation: isSimulationExpanded, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded }); }; const updateSimulationExpanded = (value: boolean) => { setIsSimulationExpanded(value); saveCardStates({ diagram: isDiagramExpanded, simulation: value, pharmacokinetic: isPharmacokineticExpanded, advanced: isAdvancedExpanded }); }; const updatePharmacokineticExpanded = (value: boolean) => { setIsPharmacokineticExpanded(value); saveCardStates({ diagram: isDiagramExpanded, simulation: isSimulationExpanded, pharmacokinetic: value, advanced: isAdvancedExpanded }); }; const updateAdvancedExpanded = (value: boolean) => { setIsAdvancedExpanded(value); saveCardStates({ diagram: isDiagramExpanded, simulation: isSimulationExpanded, pharmacokinetic: isPharmacokineticExpanded, advanced: value }); }; const saveCardStates = (states: any) => { localStorage.setItem('settingsCardStates_v1', JSON.stringify(states)); }; // Create defaults object for translation interpolation const defaultsForT = getDefaultsForTranslation(pkParams, therapeuticRange, uiSettings); // Validate range inputs React.useEffect(() => { // Therapeutic range validation (blocking error) const minTherapeutic = parseFloat(therapeuticRange.min); const maxTherapeutic = parseFloat(therapeuticRange.max); if (!isNaN(minTherapeutic) && !isNaN(maxTherapeutic) && minTherapeutic >= maxTherapeutic) { setTherapeuticRangeError(t('errorTherapeuticRangeInvalid')); } else { setTherapeuticRangeError(''); } // Y-axis range validation (non-blocking warning) const minYAxis = parseFloat(yAxisMin); const maxYAxis = parseFloat(yAxisMax); if (!isNaN(minYAxis) && !isNaN(maxYAxis) && minYAxis >= maxYAxis) { setYAxisRangeError(t('errorYAxisRangeInvalid')); } else { setYAxisRangeError(''); } }, [therapeuticRange.min, therapeuticRange.max, yAxisMin, yAxisMax, t]); // Helper to update nested advanced settings const updateAdvanced = (category: string, key: string, value: any) => { onUpdatePkParams('advanced', { ...pkParams.advanced, [category]: { ...pkParams.advanced[category], [key]: value } }); }; const updateAdvancedDirect = (key: string, value: any) => { onUpdatePkParams('advanced', { ...pkParams.advanced, [key]: value }); }; // Check for out-of-range warnings const absorptionHL = parseFloat(pkParams.ldx.absorptionHalfLife); const conversionHL = parseFloat(pkParams.ldx.halfLife); const eliminationHL = parseFloat(pkParams.damph.halfLife); const absorptionWarning = (absorptionHL < 0.7 || absorptionHL > 1.2); const conversionWarning = (conversionHL < 0.7 || conversionHL > 1.2); const eliminationWarning = (eliminationHL < 9 || eliminationHL > 12); const eliminationExtreme = (eliminationHL < 7 || eliminationHL > 15); return (
{/* Diagram Settings Card */} updateDiagramExpanded(!isDiagramExpanded)} /> {isDiagramExpanded && (
onUpdateUiSetting('showTemplateDay', checked)} /> setOpenTooltipId(open ? 'showTemplateDay' : null)}>

{tWithDefaults(t, 'showTemplateDayTooltip', defaultsForT)}

onUpdateUiSetting('showDayReferenceLines', checked)} /> setOpenTooltipId(open ? 'showDayReferenceLines' : null)}>

{tWithDefaults(t, 'showDayReferenceLinesTooltip', defaultsForT)}

onUpdateUiSetting('showTherapeuticRange', checked)} /> setOpenTooltipId(open ? 'showTherapeuticRangeLines' : null)}>

{tWithDefaults(t, 'showTherapeuticRangeLinesTooltip', defaultsForT)}

{showTherapeuticRange && (
setOpenTooltipId(open ? 'therapeuticRange' : null)}>

{tWithDefaults(t, 'therapeuticRangeTooltip', defaultsForT)}

onUpdateTherapeuticRange('min', val)} increment={0.5} min={0} max={500} placeholder={t('min')} required={true} error={!!therapeuticRangeError || !therapeuticRange.min} errorMessage={therapeuticRangeError || t('errorTherapeuticRangeMinRequired') || 'Minimum therapeutic range is required'} showResetButton={true} defaultValue={defaultsForT.therapeuticRangeMin} /> - onUpdateTherapeuticRange('max', val)} increment={0.5} min={0} max={500} placeholder={t('max')} unit="ng/ml" required={true} error={!!therapeuticRangeError || !therapeuticRange.max} errorMessage={therapeuticRangeError || t('errorTherapeuticRangeMaxRequired') || 'Maximum therapeutic range is required'} showResetButton={true} defaultValue={defaultsForT.therapeuticRangeMax} />
)}
setOpenTooltipId(open ? 'displayedDays' : null)}>

{tWithDefaults(t, 'displayedDaysTooltip', defaultsForT)}

onUpdateUiSetting('displayedDays', val)} increment={1} min={1} max={parseInt(simulationDays, 10) || 3} unit={t('unitDays')} required={true} errorMessage={t('errorNumberRequired')} showResetButton={true} defaultValue={defaultsForT.displayedDays} />
setOpenTooltipId(open ? 'yAxisRange' : null)}>

{tWithDefaults(t, 'yAxisRangeTooltip', defaultsForT)}

onUpdateUiSetting('yAxisMin', val)} increment={1} min={0} max={500} placeholder={t('auto')} allowEmpty={true} showResetButton={true} defaultValue={defaultsForT.yAxisMin} warning={!!yAxisRangeError} warningMessage={yAxisRangeError} /> - onUpdateUiSetting('yAxisMax', val)} increment={1} min={0} max={500} placeholder={t('auto')} unit="ng/ml" allowEmpty={true} showResetButton={true} defaultValue={defaultsForT.yAxisMax} warning={!!yAxisRangeError} warningMessage={yAxisRangeError} />
)}
{/* Simulation Settings Card */} updateSimulationExpanded(!isSimulationExpanded)} /> {isSimulationExpanded && (
setOpenTooltipId(open ? 'simulationDuration' : null)}>

{tWithDefaults(t, 'simulationDurationTooltip', defaultsForT)}

onUpdateUiSetting('simulationDays', val)} increment={1} min={3} max={7} unit={t('unitDays')} required={true} errorMessage={t('errorNumberRequired')} showResetButton={true} defaultValue={defaultsForT.simulationDays} />
{ onUpdateUiSetting('steadyStateDaysEnabled', checked); // When toggling off, set steadyStateDays to '0' if (!checked) { updateAdvancedDirect('steadyStateDays', '0'); } else { // When toggling on, set to default 7 if it's currently 0 if (pkParams.advanced.steadyStateDays === '0') { updateAdvancedDirect('steadyStateDays', '7'); } } }} /> setOpenTooltipId(open ? 'steadyStateDays' : null)}>

{tWithDefaults(t, 'steadyStateDaysTooltip', defaultsForT)}

{steadyStateDaysEnabled && (
updateAdvancedDirect('steadyStateDays', val)} increment={1} min={0} max={7} unit={t('unitDays')} required={true} showResetButton={true} defaultValue={defaultsForT.steadyStateDays} />
)}
)}
{/* Pharmacokinetic Settings Card */} updatePharmacokineticExpanded(!isPharmacokineticExpanded)} /> {isPharmacokineticExpanded && (

{t('dAmphetamineParameters')}

setOpenTooltipId(open ? 'halfLife' : null)}>

{renderTooltipWithLinks(tWithDefaults(t, 'halfLifeTooltip', defaultsForT))}

onUpdatePkParams('damph', { ...pkParams.damph, halfLife: val })} increment={0.5} min={5} max={50} unit="h" required={true} warning={eliminationWarning && !eliminationExtreme} error={eliminationExtreme} warningMessage={t('warningEliminationOutOfRange')} errorMessage={t('errorEliminationHalfLifeRequired')} showResetButton={true} defaultValue={defaultsForT.damphHalfLife} />

{t('lisdexamfetamineParameters')}

setOpenTooltipId(open ? 'conversionHalfLife' : null)}>

{tWithDefaults(t, 'conversionHalfLifeTooltip', defaultsForT)}

onUpdatePkParams('ldx', { ...pkParams.ldx, halfLife: val })} increment={0.1} min={0.5} max={5} unit="h" required={true} warning={conversionWarning} warningMessage={t('warningConversionOutOfRange')} errorMessage={t('errorConversionHalfLifeRequired')} showResetButton={true} defaultValue={defaultsForT.ldxHalfLife} />
setOpenTooltipId(open ? 'absorptionHalfLife' : null)}>

{tWithDefaults(t, 'absorptionHalfLifeTooltip', defaultsForT)}

onUpdatePkParams('ldx', { ...pkParams.ldx, absorptionHalfLife: val })} increment={0.1} min={0.5} max={5} unit="h" required={true} warning={absorptionWarning} warningMessage={t('warningAbsorptionOutOfRange')} errorMessage={t('errorAbsorptionRateRequired')} showResetButton={true} defaultValue={defaultsForT.ldxAbsorptionHalfLife} />
)}
{/* Advanced Settings Card */} updateAdvancedExpanded(!isAdvancedExpanded)} /> {isAdvancedExpanded && (

{t('advancedSettingsWarning')}

{/* Standard Volume of Distribution */}
setOpenTooltipId(open ? 'standardVd' : null)}>

{renderTooltipWithLinks(tWithDefaults(t, 'standardVdTooltip', { ...defaultsForT, standardVdValue: pkParams.advanced.standardVd?.preset === 'adult' ? '377' : pkParams.advanced.standardVd?.preset === 'child' ? '175' : pkParams.advanced.standardVd?.customValue || '377', standardVdPreset: t(`standardVdPreset${pkParams.advanced.standardVd?.preset?.charAt(0).toUpperCase()}${pkParams.advanced.standardVd?.preset?.slice(1)}` || 'standardVdPresetAdult') }))}

{pkParams.advanced.standardVd?.preset === 'weight-based' && (
ⓘ {t('weightBasedVdInfo')}
)} {pkParams.advanced.standardVd?.preset === 'custom' && (
updateAdvanced('standardVd', 'customValue', val)} increment={10} min={50} max={2000} unit="L" required={true} showResetButton={true} defaultValue={defaultsForT.customVdValue} />
)} {pkParams.advanced.standardVd?.preset === 'weight-based' && (
setOpenTooltipId(open ? 'bodyWeight' : null)}>

{renderTooltipWithLinks(tWithDefaults(t, 'bodyWeightTooltip', defaultsForT))}

updateAdvanced('standardVd', 'bodyWeight', val)} increment={1} min={20} max={300} unit={t('bodyWeightUnit')} required={true} showResetButton={true} defaultValue={defaultsForT.bodyWeight} />
)}
{/* Food Effect Absorption Delay */}
setOpenTooltipId(open ? 'tmaxDelay' : null)}>

{renderTooltipWithLinks(tWithDefaults(t, 'tmaxDelayTooltip', defaultsForT))}

updateAdvanced('foodEffect', 'tmaxDelay', val)} increment={0.1} min={0} max={5} unit={t('tmaxDelayUnit')} required={true} showResetButton={true} defaultValue={defaultsForT.tmaxDelay} />
{/* Urine pH */}
setOpenTooltipId(open ? 'urinePH' : null)}>

{tWithDefaults(t, 'urinePHTooltip', defaultsForT)}

{/* Age Group Selection */}
setOpenTooltipId(open ? 'ageGroup' : null)}>

{renderTooltipWithLinks(tWithDefaults(t, 'ageGroupTooltip', defaultsForT))}

{/* Renal Function */}
{ updateAdvancedDirect('renalFunction', { enabled: checked, severity: pkParams.advanced.renalFunction?.severity || 'normal' }); }} /> setOpenTooltipId(open ? 'renalFunction' : null)}>

{renderTooltipWithLinks(tWithDefaults(t, 'renalFunctionTooltip', defaultsForT))}

{(pkParams.advanced.renalFunction?.enabled) && (
)}
{/* Oral Bioavailability */}
setOpenTooltipId(open ? 'oralBioavailability' : null)}>

{renderTooltipWithLinks(tWithDefaults(t, 'oralBioavailabilityTooltip', defaultsForT))}

updateAdvancedDirect('fOral', val)} increment={0.01} min={0.5} max={1.0} required={true} showResetButton={true} defaultValue={defaultsForT.fOral} />
)}
{/* Data Management Button */}
); }; export default Settings;