diff --git a/package.json b/package.json index 3fd63dc..40cbbb7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "med-plan-assistant", - "version": "0.1.0", + "version": "0.1.1", "private": true, "dependencies": { "@testing-library/dom": "^10.4.1", diff --git a/src/App.js b/src/App.js index 91ea3ff..f0b03ae 100644 --- a/src/App.js +++ b/src/App.js @@ -1,352 +1,54 @@ import React from 'react'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts'; -// --- Constants --- -const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v5'; -const LDX_TO_DAMPH_CONVERSION_FACTOR = 0.2948; - - -// --- Helper Functions --- -const timeToMinutes = (timeStr) => { - if (!timeStr || !timeStr.includes(':')) return 0; - const [hours, minutes] = timeStr.split(':').map(Number); - return hours * 60 + minutes; -}; - -// --- Default State --- -const getDefaultState = () => ({ - pkParams: { - damph: { halfLife: '11' }, - ldx: { halfLife: '0.8', absorptionRate: '1.5' }, - }, - doses: [ - { time: '06:30', dose: '25', label: 'Morgens' }, - { time: '12:30', dose: '10', label: 'Mittags' }, - { time: '17:00', dose: '10', label: 'Nachmittags' }, - { time: '21:00', dose: '10', label: 'Abends' }, - { time: '01:00', dose: '0', label: 'Nachts' }, - ], - steadyStateConfig: { daysOnMedication: '7' }, - therapeuticRange: { min: '11.5', max: '14' }, - doseIncrement: '2.5', - uiSettings: { - showDayTimeXAxis: true, - chartView: 'damph', - yAxisMin: '', - yAxisMax: '', - simulationDays: '3', - displayedDays: '2', - } -}); - -// --- Custom Components --- -const TimeInput = ({ value, onChange }) => { - const [displayValue, setDisplayValue] = React.useState(value); - const [isPickerOpen, setIsPickerOpen] = React.useState(false); - const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number); - React.useEffect(() => { setDisplayValue(value); }, [value]); - const handleBlur = (e) => { - let input = e.target.value.replace(/[^0-9]/g, ''); - let hours = '00', minutes = '00'; - if (input.length <= 2) { hours = input.padStart(2, '0'); } - else if (input.length === 3) { hours = input.substring(0, 1).padStart(2, '0'); minutes = input.substring(1, 3); } - else { hours = input.substring(0, 2); minutes = input.substring(2, 4); } - hours = Math.min(23, parseInt(hours, 10) || 0).toString().padStart(2, '0'); - minutes = Math.min(59, parseInt(minutes, 10) || 0).toString().padStart(2, '0'); - const formattedTime = `${hours}:${minutes}`; - setDisplayValue(formattedTime); - onChange(formattedTime); - }; - const handleChange = (e) => { setDisplayValue(e.target.value); }; - const handlePickerChange = (part, val) => { - let newHours = pickerHours, newMinutes = pickerMinutes; - if (part === 'h') { newHours = val; } else { newMinutes = val; } - const formattedTime = `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`; - onChange(formattedTime); - }; - return ( -
- - - {isPickerOpen && ( -
-
{value}
-
-
Stunde:
-
{[...Array(24).keys()].map(h => ())}
-
-
-
Minute:
-
{[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55].map(m => ())}
-
- -
- )} -
- ); -}; - -const NumericInput = ({ value, onChange, increment, min = -Infinity, max = Infinity, placeholder, unit }) => { - const updateValue = (direction) => { - const numIncrement = parseFloat(increment) || 1; - let numValue = parseFloat(value) || 0; - numValue += direction * numIncrement; - numValue = Math.max(min, numValue); - numValue = Math.min(max, numValue); - const finalValue = String(Math.round(numValue * 100) / 100); - onChange(finalValue); - }; - const handleKeyDown = (e) => { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); updateValue(e.key === 'ArrowUp' ? 1 : -1); } }; - const handleChange = (e) => { const val = e.target.value; if (val === '' || /^-?\d*\.?\d*$/.test(val)) { onChange(val); } }; - return ( -
- - - - {unit && {unit}} -
- ); -}; +// Components +import DoseSchedule from './components/DoseSchedule.js'; +import DeviationList from './components/DeviationList.js'; +import SuggestionPanel from './components/SuggestionPanel.js'; +import SimulationChart from './components/SimulationChart.js'; +import Settings from './components/Settings.js'; +// Custom Hooks +import { useAppState } from './hooks/useAppState.js'; +import { useSimulation } from './hooks/useSimulation.js'; // --- Main Component --- const MedPlanAssistant = () => { - const [appState, setAppState] = React.useState(getDefaultState); - const [isLoaded, setIsLoaded] = React.useState(false); + const { + appState, + updateState, + updateNestedState, + updateUiSetting, + handleReset + } = useAppState(); - React.useEffect(() => { - try { - const savedState = window.localStorage.getItem(LOCAL_STORAGE_KEY); - if (savedState) { - const parsedState = JSON.parse(savedState); - const defaults = getDefaultState(); - setAppState({ - ...defaults, ...parsedState, - pkParams: {...defaults.pkParams, ...parsedState.pkParams}, - uiSettings: {...defaults.uiSettings, ...parsedState.uiSettings}, - }); - } - } catch (error) { console.error("Failed to load state", error); } - setIsLoaded(true); - }, []); + const { + pkParams, + doses, + therapeuticRange, + doseIncrement, + uiSettings + } = appState; + + const { + showDayTimeXAxis, + chartView, + yAxisMin, + yAxisMax, + simulationDays, + displayedDays + } = uiSettings; - React.useEffect(() => { - if (isLoaded) { - try { - const stateToSave = { - pkParams: appState.pkParams, - doses: appState.doses, - steadyStateConfig: appState.steadyStateConfig, - therapeuticRange: appState.therapeuticRange, - doseIncrement: appState.doseIncrement, - uiSettings: appState.uiSettings, - }; - window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(stateToSave)); - } catch (error) { console.error("Failed to save state", error); } - } - }, [appState, isLoaded]); - - const { pkParams, doses, steadyStateConfig, therapeuticRange, doseIncrement, uiSettings } = appState; - const { showDayTimeXAxis, chartView, yAxisMin, yAxisMax, simulationDays, displayedDays } = uiSettings; - - const [deviations, setDeviations] = React.useState([]); - const [suggestion, setSuggestion] = React.useState(null); - - const updateState = (key, value) => { setAppState(prev => ({ ...prev, [key]: value })); }; - const updateNestedState = (parentKey, childKey, value) => { setAppState(prev => ({ ...prev, [parentKey]: { ...prev[parentKey], ...value } })); }; - const updateUiSetting = (key, value) => { - const newUiSettings = { ...appState.uiSettings, [key]: value }; - if (key === 'simulationDays') { - const simDaysNum = parseInt(value, 10) || 1; - const dispDaysNum = parseInt(newUiSettings.displayedDays, 10) || 1; - if (dispDaysNum > simDaysNum) { - newUiSettings.displayedDays = String(simDaysNum); - } - } - setAppState(prev => ({ ...prev, uiSettings: newUiSettings })); - }; - - const calculateSingleDoseConcentration = React.useCallback((dose, timeSinceDoseHours) => { - const numDose = parseFloat(dose) || 0; - if (timeSinceDoseHours < 0 || numDose <= 0) return { ldx: 0, damph: 0 }; - const ka_ldx = Math.log(2) / (parseFloat(pkParams.ldx.absorptionRate) || 1); - const k_conv = Math.log(2) / (parseFloat(pkParams.ldx.halfLife) || 1); - const ke_damph = Math.log(2) / (parseFloat(pkParams.damph.halfLife) || 1); - let ldxConcentration = 0; - if (Math.abs(ka_ldx - k_conv) > 0.0001) { - ldxConcentration = (numDose * ka_ldx / (ka_ldx - k_conv)) * (Math.exp(-k_conv * timeSinceDoseHours) - Math.exp(-ka_ldx * timeSinceDoseHours)); - } - let damphConcentration = 0; - if (Math.abs(ka_ldx - ke_damph) > 0.0001 && Math.abs(k_conv - ke_damph) > 0.0001 && Math.abs(ka_ldx - k_conv) > 0.0001) { - const term1 = Math.exp(-ke_damph * timeSinceDoseHours) / ((ka_ldx - ke_damph) * (k_conv - ke_damph)); - const term2 = Math.exp(-k_conv * timeSinceDoseHours) / ((ka_ldx - k_conv) * (ke_damph - k_conv)); - const term3 = Math.exp(-ka_ldx * timeSinceDoseHours) / ((k_conv - ka_ldx) * (ke_damph - ka_ldx)); - damphConcentration = LDX_TO_DAMPH_CONVERSION_FACTOR * numDose * ka_ldx * k_conv * (term1 + term2 + term3); - } - return { ldx: Math.max(0, ldxConcentration), damph: Math.max(0, damphConcentration) }; - }, [pkParams]); - - const calculateCombinedProfile = React.useCallback((doseSchedule, deviationList = [], correction = null) => { - const dataPoints = []; - const timeStepHours = 0.25; - const totalHours = (parseInt(simulationDays, 10) || 3) * 24; - const daysToSimulate = Math.min(parseInt(steadyStateConfig.daysOnMedication, 10) || 0, 5); - for (let t = 0; t <= totalHours; t += timeStepHours) { - let totalLdx = 0; - let totalDamph = 0; - let allDoses = []; - const maxDayOffset = (parseInt(simulationDays, 10) || 3) -1; - for (let day = -daysToSimulate; day <= maxDayOffset; day++) { - const dayOffset = day * 24 * 60; - doseSchedule.forEach(d => { allDoses.push({ ...d, time: timeToMinutes(d.time) + dayOffset, isPlan: true }); }); - } - - const currentDeviations = [...deviationList]; - if (correction) { - currentDeviations.push({ ...correction, isAdditional: true }); - } - - currentDeviations.forEach(dev => { - const devTime = timeToMinutes(dev.time) + (dev.dayOffset || 0) * 24 * 60; - if (!dev.isAdditional) { - const closestDoseIndex = allDoses.reduce((closest, dose, index) => { - if (!dose.isPlan) return closest; - const diff = Math.abs(dose.time - devTime); - if (diff <= 60 && diff < closest.minDiff) { return { index, minDiff: diff }; } - return closest; - }, { index: -1, minDiff: 61 }).index; - if (closestDoseIndex !== -1) { allDoses.splice(closestDoseIndex, 1); } - } - allDoses.push({ ...dev, time: devTime }); - }); - - allDoses.forEach(doseInfo => { - const timeSinceDoseHours = t - doseInfo.time / 60; - const concentrations = calculateSingleDoseConcentration(doseInfo.dose, timeSinceDoseHours); - totalLdx += concentrations.ldx; - totalDamph += concentrations.damph; - }); - dataPoints.push({ timeHours: t, ldx: totalLdx, damph: totalDamph }); - } - return dataPoints; - }, [steadyStateConfig, calculateSingleDoseConcentration, simulationDays]); - - const generateSuggestion = React.useCallback(() => { - if (deviations.length === 0) { - setSuggestion(null); - return; - } - const lastDeviation = [...deviations].sort((a, b) => timeToMinutes(a.time) + (a.dayOffset || 0) * 1440 - (timeToMinutes(b.time) + (b.dayOffset || 0) * 1440)).pop(); - const deviationTimeTotalMinutes = timeToMinutes(lastDeviation.time) + (lastDeviation.dayOffset || 0) * 1440; - - let nextDose = null; - let minDiff = Infinity; - - doses.forEach(d => { - const doseTimeInMinutes = timeToMinutes(d.time); - for (let i=0; i < (parseInt(simulationDays, 10) || 1); i++) { - const absoluteTime = doseTimeInMinutes + i * 1440; - const diff = absoluteTime - deviationTimeTotalMinutes; - if (diff > 0 && diff < minDiff) { - minDiff = diff; - nextDose = {...d, dayOffset: i}; - } - } - }); - - if (!nextDose) { - setSuggestion({ text: "Keine passende nächste Dosis für Korrektur gefunden." }); - return; - } - - const numDoseIncrement = parseFloat(doseIncrement) || 1; - const idealProfile = calculateCombinedProfile(doses); - const deviatedProfile = calculateCombinedProfile(doses, deviations); - - const nextDoseTimeHours = (timeToMinutes(nextDose.time) + (nextDose.dayOffset || 0) * 1440) / 60; - - const idealConcentration = idealProfile.find(p => Math.abs(p.timeHours - nextDoseTimeHours) < 0.1)?.damph || 0; - const deviatedConcentration = deviatedProfile.find(p => Math.abs(p.timeHours - nextDoseTimeHours) < 0.1)?.damph || 0; - const concentrationDifference = idealConcentration - deviatedConcentration; - - if (Math.abs(concentrationDifference) < 0.5) { - setSuggestion({ text: "Keine signifikante Korrektur notwendig." }); - return; - } - - const doseAdjustmentFactor = 0.5; - let doseChange = concentrationDifference / doseAdjustmentFactor; - doseChange = Math.round(doseChange / numDoseIncrement) * numDoseIncrement; - let suggestedDoseValue = (parseFloat(nextDose.dose) || 0) + doseChange; - suggestedDoseValue = Math.max(0, Math.min(70, suggestedDoseValue)); - - setSuggestion({ - time: nextDose.time, - dose: String(suggestedDoseValue), - isAdditional: false, - originalDose: nextDose.dose, - dayOffset: nextDose.dayOffset - }); - }, [doses, deviations, calculateCombinedProfile, doseIncrement, simulationDays]); - - React.useEffect(() => { - generateSuggestion(); - }, [deviations, doses, pkParams, doseIncrement, generateSuggestion]); - - - const idealProfile = React.useMemo(() => calculateCombinedProfile(doses), [doses, calculateCombinedProfile]); - const deviatedProfile = React.useMemo(() => deviations.length > 0 ? calculateCombinedProfile(doses, deviations) : null, [doses, deviations, calculateCombinedProfile]); - const correctedProfile = React.useMemo(() => suggestion && suggestion.dose ? calculateCombinedProfile(doses, deviations, suggestion) : null, [doses, deviations, suggestion, calculateCombinedProfile]); - - const handleReset = () => { - if (window.confirm("Bist du sicher, dass du alle Einstellungen auf die Standardwerte zurücksetzen möchtest? Dies kann nicht rückgängig gemacht werden.")) { - window.localStorage.removeItem(LOCAL_STORAGE_KEY); - window.location.reload(); - } - }; - - const addDeviation = () => { - const sortedDoses = [...doses].sort((a,b) => timeToMinutes(a.time) - timeToMinutes(b.time)); - let nextDose = sortedDoses[0] || { time: '08:00', dose: '25' }; - if (deviations.length > 0) { - const lastDev = deviations[deviations.length - 1]; - const lastDevTime = timeToMinutes(lastDev.time) + (lastDev.dayOffset || 0) * 24 * 60; - const nextPlanned = sortedDoses.find(d => timeToMinutes(d.time) > (lastDevTime % (24*60))); - if (nextPlanned) { - nextDose = { ...nextPlanned, dayOffset: lastDev.dayOffset }; - } else { - nextDose = { ...sortedDoses[0], dayOffset: (lastDev.dayOffset || 0) + 1 }; - } - } - setDeviations([...deviations, { ...nextDose, isAdditional: false, dayOffset: nextDose.dayOffset || 0 }]); - }; - - const removeDeviation = (index) => { setDeviations(deviations.filter((_, i) => i !== index)); }; - const handleDeviationChange = (index, field, value) => { - const newDeviations = [...deviations]; - newDeviations[index][field] = value; - setDeviations(newDeviations); - }; - - const applySuggestion = () => { - if (!suggestion || !suggestion.dose) return; - setDeviations([...deviations, suggestion]); - setSuggestion(null); - } - - const chartDomain = React.useMemo(() => { - const numMin = parseFloat(yAxisMin); - const numMax = parseFloat(yAxisMax); - const domainMin = !isNaN(numMin) ? numMin : 'auto'; - const domainMax = !isNaN(numMax) ? numMax : 'auto'; - return [domainMin, domainMax]; - }, [yAxisMin, yAxisMax]); - - const totalHours = (parseInt(simulationDays, 10) || 3) * 24; - const chartTicks = Array.from({length: Math.floor(totalHours / 6) + 1}, (_, i) => i * 6); - const chartWidthPercentage = Math.max(100, (totalHours / ( (parseInt(displayedDays, 10) || 2) * 24)) * 100); + const { + deviations, + suggestion, + idealProfile, + deviatedProfile, + correctedProfile, + addDeviation, + removeDeviation, + handleDeviationChange, + applySuggestion + } = useSimulation(appState); return (
@@ -355,134 +57,89 @@ const MedPlanAssistant = () => {

Medikationsplan-Assistent

Simulation für Lisdexamfetamin (LDX) und d-Amphetamin (d-amph)

+
+ {/* Left Column - Controls */}
-
-

Mein Plan

- {doses.map((dose, index) => ( -
- updateState('doses', doses.map((d, i) => i === index ? {...d, time: newTime} : d))} /> -
- updateState('doses', doses.map((d, i) => i === index ? {...d, dose: newDose} : d))} increment={doseIncrement} min={0} unit="mg" /> -
- {dose.label} -
- ))} -
+ updateState('doses', newDoses)} + /> -
-

Abweichungen vom Plan

- {deviations.map((dev, index) => ( -
- - handleDeviationChange(index, 'time', newTime)} /> -
- handleDeviationChange(index, 'dose', newDose)} increment={doseIncrement} min={0} unit="mg"/> -
- -
- handleDeviationChange(index, 'isAdditional', e.target.checked)} className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500" /> - -
-
- ))} - -
- - {suggestion && ( -
-

Was wäre wenn?

- {suggestion.dose ? ( - <> -

Vorschlag: {suggestion.dose}mg (statt {suggestion.originalDose}mg) um {suggestion.time}.

- - - ) : ( -

{suggestion.text}

- )} -
- )} + +
+ + {/* Center Column - Chart */}
- - - -
-
-
- - - - `${h}h`} xAxisId="continuous" /> - {showDayTimeXAxis && `${h % 24}h`} xAxisId="daytime" orientation="top" />} - - [`${value.toFixed(1)} ng/ml`, name]} labelFormatter={(label) => `Stunde: ${label}h`}/> - - {(chartView === 'damph' || chartView === 'both') && } - {(chartView === 'damph' || chartView === 'both') && } - - {[...Array(parseInt(simulationDays, 10) || 0).keys()].map(day => ( - day > 0 && - ))} - - {(chartView === 'damph' || chartView === 'both') && } - {(chartView === 'ldx' || chartView === 'both') && } - - {deviatedProfile && (chartView === 'damph' || chartView === 'both') && } - {deviatedProfile && (chartView === 'ldx' || chartView === 'both') && } - - {correctedProfile && (chartView === 'damph' || chartView === 'both') && } - {correctedProfile && (chartView === 'ldx' || chartView === 'both') && } - - - -
+ + +
+ +
+ + {/* Right Column - Settings */}
-
-

Erweiterte Einstellungen

-
-
updateUiSetting('showDayTimeXAxis', e.target.checked)} className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500" />
-
updateUiSetting('simulationDays', val)} increment={'1'} min={2} max={7} unit="Tage"/>
-
updateUiSetting('displayedDays', val)} increment={'1'} min={1} max={parseInt(simulationDays, 10) || 1} unit="Tage"/>
- -
-
updateUiSetting('yAxisMin', val)} increment={'5'} min={0} placeholder="Auto" unit="ng/ml"/>
- - -
updateUiSetting('yAxisMax', val)} increment={'5'} min={0} placeholder="Auto" unit="ng/ml"/>
-
- -
-
updateNestedState('therapeuticRange', 'min', val)} increment={'0.5'} min={0} placeholder="Min" unit="ng/ml"/>
- - -
updateNestedState('therapeuticRange', 'max', val)} increment={'0.5'} min={0} placeholder="Max" unit="ng/ml"/>
-
-

d-Amphetamin Parameter

-
updateNestedState('pkParams', 'damph', { halfLife: val })} increment={'0.5'} min={0.1} unit="h"/>
-

Lisdexamfetamin Parameter

-
updateNestedState('pkParams', 'ldx', { halfLife: val })} increment={'0.1'} min={0.1} unit="h"/>
-
updateNestedState('pkParams', 'ldx', { absorptionRate: val })} increment={'0.1'} min={0.1} unit="(schneller >)"/>
-
- -
-
-
+ updateNestedState('pkParams', key, value)} + onUpdateTherapeuticRange={(key, value) => updateNestedState('therapeuticRange', key, { [key]: value })} + onUpdateUiSetting={updateUiSetting} + onReset={handleReset} + />
+
- ) + ); }; -export default MedPlanAssistant; +export default MedPlanAssistant; \ No newline at end of file diff --git a/src/components/DeviationList.js b/src/components/DeviationList.js new file mode 100644 index 0000000..fd60637 --- /dev/null +++ b/src/components/DeviationList.js @@ -0,0 +1,70 @@ +import React from 'react'; +import TimeInput from './TimeInput.js'; +import NumericInput from './NumericInput.js'; + +const DeviationList = ({ + deviations, + doseIncrement, + simulationDays, + onAddDeviation, + onRemoveDeviation, + onDeviationChange +}) => { + return ( +
+

Abweichungen vom Plan

+ {deviations.map((dev, index) => ( +
+ + onDeviationChange(index, 'time', newTime)} + /> +
+ onDeviationChange(index, 'dose', newDose)} + increment={doseIncrement} + min={0} + unit="mg" + /> +
+ +
+ onDeviationChange(index, 'isAdditional', e.target.checked)} + className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500" + /> + +
+
+ ))} + +
+ ); +}; + +export default DeviationList; diff --git a/src/components/DoseSchedule.js b/src/components/DoseSchedule.js new file mode 100644 index 0000000..b78d996 --- /dev/null +++ b/src/components/DoseSchedule.js @@ -0,0 +1,31 @@ +import React from 'react'; +import TimeInput from './TimeInput.js'; +import NumericInput from './NumericInput.js'; + +const DoseSchedule = ({ doses, doseIncrement, onUpdateDoses }) => { + return ( +
+

Mein Plan

+ {doses.map((dose, index) => ( +
+ onUpdateDoses(doses.map((d, i) => i === index ? {...d, time: newTime} : d))} + /> +
+ onUpdateDoses(doses.map((d, i) => i === index ? {...d, dose: newDose} : d))} + increment={doseIncrement} + min={0} + unit="mg" + /> +
+ {dose.label} +
+ ))} +
+ ); +}; + +export default DoseSchedule; diff --git a/src/components/NumericInput.js b/src/components/NumericInput.js new file mode 100644 index 0000000..9184f3c --- /dev/null +++ b/src/components/NumericInput.js @@ -0,0 +1,55 @@ +import React from 'react'; + +const NumericInput = ({ value, onChange, increment, min = -Infinity, max = Infinity, placeholder, unit }) => { + const updateValue = (direction) => { + const numIncrement = parseFloat(increment) || 1; + let numValue = parseFloat(value) || 0; + numValue += direction * numIncrement; + numValue = Math.max(min, numValue); + numValue = Math.min(max, numValue); + const finalValue = String(Math.round(numValue * 100) / 100); + onChange(finalValue); + }; + + const handleKeyDown = (e) => { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + updateValue(e.key === 'ArrowUp' ? 1 : -1); + } + }; + + const handleChange = (e) => { + const val = e.target.value; + if (val === '' || /^-?\d*\.?\d*$/.test(val)) { + onChange(val); + } + }; + + return ( +
+ + + + {unit && {unit}} +
+ ); +}; + +export default NumericInput; diff --git a/src/components/Settings.js b/src/components/Settings.js new file mode 100644 index 0000000..154830c --- /dev/null +++ b/src/components/Settings.js @@ -0,0 +1,153 @@ +import React from 'react'; +import NumericInput from './NumericInput.js'; + +const Settings = ({ + pkParams, + therapeuticRange, + uiSettings, + onUpdatePkParams, + onUpdateTherapeuticRange, + onUpdateUiSetting, + onReset +}) => { + const { showDayTimeXAxis, yAxisMin, yAxisMax, simulationDays, displayedDays } = uiSettings; + + return ( +
+

Erweiterte Einstellungen

+
+
+ onUpdateUiSetting('showDayTimeXAxis', e.target.checked)} + className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500" + /> + +
+ + +
+ onUpdateUiSetting('simulationDays', val)} + increment={'1'} + min={2} + max={7} + unit="Tage" + /> +
+ + +
+ onUpdateUiSetting('displayedDays', val)} + increment={'1'} + min={1} + max={parseInt(simulationDays, 10) || 1} + unit="Tage" + /> +
+ + +
+
+ onUpdateUiSetting('yAxisMin', val)} + increment={'5'} + min={0} + placeholder="Auto" + unit="ng/ml" + /> +
+ - +
+ onUpdateUiSetting('yAxisMax', val)} + increment={'5'} + min={0} + placeholder="Auto" + unit="ng/ml" + /> +
+
+ + +
+
+ onUpdateTherapeuticRange('min', val)} + increment={'0.5'} + min={0} + placeholder="Min" + unit="ng/ml" + /> +
+ - +
+ onUpdateTherapeuticRange('max', val)} + increment={'0.5'} + min={0} + placeholder="Max" + unit="ng/ml" + /> +
+
+ +

d-Amphetamin Parameter

+
+ + onUpdatePkParams('damph', { halfLife: val })} + increment={'0.5'} + min={0.1} + unit="h" + /> +
+ +

Lisdexamfetamin Parameter

+
+ + onUpdatePkParams('ldx', { halfLife: val })} + increment={'0.1'} + min={0.1} + unit="h" + /> +
+
+ + onUpdatePkParams('ldx', { absorptionRate: val })} + increment={'0.1'} + min={0.1} + unit="(schneller >)" + /> +
+ +
+ +
+
+
+ ); +}; + +export default Settings; diff --git a/src/components/SimulationChart.js b/src/components/SimulationChart.js new file mode 100644 index 0000000..33de73c --- /dev/null +++ b/src/components/SimulationChart.js @@ -0,0 +1,181 @@ +import React from 'react'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts'; + +const SimulationChart = ({ + idealProfile, + deviatedProfile, + correctedProfile, + chartView, + showDayTimeXAxis, + therapeuticRange, + simulationDays, + displayedDays, + yAxisMin, + yAxisMax +}) => { + const totalHours = (parseInt(simulationDays, 10) || 3) * 24; + const chartTicks = Array.from({length: Math.floor(totalHours / 6) + 1}, (_, i) => i * 6); + const chartWidthPercentage = Math.max(100, (totalHours / ( (parseInt(displayedDays, 10) || 2) * 24)) * 100); + + const chartDomain = React.useMemo(() => { + const numMin = parseFloat(yAxisMin); + const numMax = parseFloat(yAxisMax); + const domainMin = !isNaN(numMin) ? numMin : 'auto'; + const domainMax = !isNaN(numMax) ? numMax : 'auto'; + return [domainMin, domainMax]; + }, [yAxisMin, yAxisMax]); + + return ( +
+
+ + + + `${h}h`} + xAxisId="continuous" + /> + {showDayTimeXAxis && ( + `${h % 24}h`} + xAxisId="daytime" + orientation="top" + /> + )} + + [`${value.toFixed(1)} ng/ml`, name]} + labelFormatter={(label) => `Stunde: ${label}h`} + /> + + + {(chartView === 'damph' || chartView === 'both') && ( + + )} + {(chartView === 'damph' || chartView === 'both') && ( + + )} + + {[...Array(parseInt(simulationDays, 10) || 0).keys()].map(day => ( + day > 0 && ( + + ) + ))} + + {(chartView === 'damph' || chartView === 'both') && ( + + )} + {(chartView === 'ldx' || chartView === 'both') && ( + + )} + + {deviatedProfile && (chartView === 'damph' || chartView === 'both') && ( + + )} + {deviatedProfile && (chartView === 'ldx' || chartView === 'both') && ( + + )} + + {correctedProfile && (chartView === 'damph' || chartView === 'both') && ( + + )} + {correctedProfile && (chartView === 'ldx' || chartView === 'both') && ( + + )} + + +
+
+ ); +}; + +export default SimulationChart; diff --git a/src/components/SuggestionPanel.js b/src/components/SuggestionPanel.js new file mode 100644 index 0000000..38b7e39 --- /dev/null +++ b/src/components/SuggestionPanel.js @@ -0,0 +1,28 @@ +import React from 'react'; + +const SuggestionPanel = ({ suggestion, onApplySuggestion }) => { + if (!suggestion) return null; + + return ( +
+

Was wäre wenn?

+ {suggestion.dose ? ( + <> +

+ Vorschlag: {suggestion.dose}mg (statt {suggestion.originalDose}mg) um {suggestion.time}. +

+ + + ) : ( +

{suggestion.text}

+ )} +
+ ); +}; + +export default SuggestionPanel; diff --git a/src/components/TimeInput.js b/src/components/TimeInput.js new file mode 100644 index 0000000..9e34d03 --- /dev/null +++ b/src/components/TimeInput.js @@ -0,0 +1,109 @@ +import React from 'react'; + +const TimeInput = ({ value, onChange }) => { + const [displayValue, setDisplayValue] = React.useState(value); + const [isPickerOpen, setIsPickerOpen] = React.useState(false); + const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number); + + React.useEffect(() => { + setDisplayValue(value); + }, [value]); + + const handleBlur = (e) => { + let input = e.target.value.replace(/[^0-9]/g, ''); + let hours = '00', minutes = '00'; + if (input.length <= 2) { + hours = input.padStart(2, '0'); + } + else if (input.length === 3) { + hours = input.substring(0, 1).padStart(2, '0'); + minutes = input.substring(1, 3); + } + else { + hours = input.substring(0, 2); + minutes = input.substring(2, 4); + } + hours = Math.min(23, parseInt(hours, 10) || 0).toString().padStart(2, '0'); + minutes = Math.min(59, parseInt(minutes, 10) || 0).toString().padStart(2, '0'); + const formattedTime = `${hours}:${minutes}`; + setDisplayValue(formattedTime); + onChange(formattedTime); + }; + + const handleChange = (e) => { + setDisplayValue(e.target.value); + }; + + const handlePickerChange = (part, val) => { + let newHours = pickerHours, newMinutes = pickerMinutes; + if (part === 'h') { + newHours = val; + } else { + newMinutes = val; + } + const formattedTime = `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`; + onChange(formattedTime); + }; + + return ( +
+ + + {isPickerOpen && ( +
+
{value}
+
+
Stunde:
+
+ {[...Array(24).keys()].map(h => ( + + ))} +
+
+
+
Minute:
+
+ {[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55].map(m => ( + + ))} +
+
+ +
+ )} +
+ ); +}; + +export default TimeInput; diff --git a/src/constants/defaults.js b/src/constants/defaults.js new file mode 100644 index 0000000..09cc0e8 --- /dev/null +++ b/src/constants/defaults.js @@ -0,0 +1,29 @@ +// --- Constants --- +export const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v5'; +export const LDX_TO_DAMPH_CONVERSION_FACTOR = 0.2948; + +// --- Default State --- +export const getDefaultState = () => ({ + pkParams: { + damph: { halfLife: '11' }, + ldx: { halfLife: '0.8', absorptionRate: '1.5' }, + }, + doses: [ + { time: '06:30', dose: '25', label: 'Morgens' }, + { time: '12:30', dose: '10', label: 'Mittags' }, + { time: '17:00', dose: '10', label: 'Nachmittags' }, + { time: '21:00', dose: '10', label: 'Abends' }, + { time: '01:00', dose: '0', label: 'Nachts' }, + ], + steadyStateConfig: { daysOnMedication: '7' }, + therapeuticRange: { min: '11.5', max: '14' }, + doseIncrement: '2.5', + uiSettings: { + showDayTimeXAxis: true, + chartView: 'damph', + yAxisMin: '', + yAxisMax: '', + simulationDays: '3', + displayedDays: '2', + } +}); diff --git a/src/hooks/useAppState.js b/src/hooks/useAppState.js new file mode 100644 index 0000000..27a0feb --- /dev/null +++ b/src/hooks/useAppState.js @@ -0,0 +1,83 @@ +import React from 'react'; +import { LOCAL_STORAGE_KEY, getDefaultState } from '../constants/defaults.js'; + +export const useAppState = () => { + const [appState, setAppState] = React.useState(getDefaultState); + const [isLoaded, setIsLoaded] = React.useState(false); + + React.useEffect(() => { + try { + const savedState = window.localStorage.getItem(LOCAL_STORAGE_KEY); + if (savedState) { + const parsedState = JSON.parse(savedState); + const defaults = getDefaultState(); + setAppState({ + ...defaults, + ...parsedState, + pkParams: {...defaults.pkParams, ...parsedState.pkParams}, + uiSettings: {...defaults.uiSettings, ...parsedState.uiSettings}, + }); + } + } catch (error) { + console.error("Failed to load state", error); + } + setIsLoaded(true); + }, []); + + React.useEffect(() => { + if (isLoaded) { + try { + const stateToSave = { + pkParams: appState.pkParams, + doses: appState.doses, + steadyStateConfig: appState.steadyStateConfig, + therapeuticRange: appState.therapeuticRange, + doseIncrement: appState.doseIncrement, + uiSettings: appState.uiSettings, + }; + window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(stateToSave)); + } catch (error) { + console.error("Failed to save state", error); + } + } + }, [appState, isLoaded]); + + const updateState = (key, value) => { + setAppState(prev => ({ ...prev, [key]: value })); + }; + + const updateNestedState = (parentKey, childKey, value) => { + setAppState(prev => ({ + ...prev, + [parentKey]: { ...prev[parentKey], [childKey]: value } + })); + }; + + const updateUiSetting = (key, value) => { + const newUiSettings = { ...appState.uiSettings, [key]: value }; + if (key === 'simulationDays') { + const simDaysNum = parseInt(value, 10) || 1; + const dispDaysNum = parseInt(newUiSettings.displayedDays, 10) || 1; + if (dispDaysNum > simDaysNum) { + newUiSettings.displayedDays = String(simDaysNum); + } + } + setAppState(prev => ({ ...prev, uiSettings: newUiSettings })); + }; + + const handleReset = () => { + if (window.confirm("Bist du sicher, dass du alle Einstellungen auf die Standardwerte zurücksetzen möchtest? Dies kann nicht rückgängig gemacht werden.")) { + window.localStorage.removeItem(LOCAL_STORAGE_KEY); + window.location.reload(); + } + }; + + return { + appState, + isLoaded, + updateState, + updateNestedState, + updateUiSetting, + handleReset + }; +}; diff --git a/src/hooks/useSimulation.js b/src/hooks/useSimulation.js new file mode 100644 index 0000000..6a9fcf1 --- /dev/null +++ b/src/hooks/useSimulation.js @@ -0,0 +1,100 @@ +import React from 'react'; +import { calculateCombinedProfile } from '../utils/calculations.js'; +import { generateSuggestion } from '../utils/suggestions.js'; +import { timeToMinutes } from '../utils/timeUtils.js'; + +export const useSimulation = (appState) => { + const { pkParams, doses, steadyStateConfig, doseIncrement, uiSettings } = appState; + const { simulationDays } = uiSettings; + + const [deviations, setDeviations] = React.useState([]); + const [suggestion, setSuggestion] = React.useState(null); + + const calculateCombinedProfileMemo = React.useCallback( + (doseSchedule, deviationList = [], correction = null) => + calculateCombinedProfile( + doseSchedule, + deviationList, + correction, + steadyStateConfig, + simulationDays, + pkParams + ), + [steadyStateConfig, simulationDays, pkParams] + ); + + const generateSuggestionMemo = React.useCallback(() => { + const newSuggestion = generateSuggestion( + doses, + deviations, + doseIncrement, + simulationDays, + steadyStateConfig, + pkParams + ); + setSuggestion(newSuggestion); + }, [doses, deviations, doseIncrement, simulationDays, steadyStateConfig, pkParams]); + + React.useEffect(() => { + generateSuggestionMemo(); + }, [generateSuggestionMemo]); + + const idealProfile = React.useMemo(() => + calculateCombinedProfileMemo(doses), + [doses, calculateCombinedProfileMemo] + ); + + const deviatedProfile = React.useMemo(() => + deviations.length > 0 ? calculateCombinedProfileMemo(doses, deviations) : null, + [doses, deviations, calculateCombinedProfileMemo] + ); + + const correctedProfile = React.useMemo(() => + suggestion && suggestion.dose ? calculateCombinedProfileMemo(doses, deviations, suggestion) : null, + [doses, deviations, suggestion, calculateCombinedProfileMemo] + ); + + const addDeviation = () => { + const sortedDoses = [...doses].sort((a,b) => timeToMinutes(a.time) - timeToMinutes(b.time)); + let nextDose = sortedDoses[0] || { time: '08:00', dose: '25' }; + if (deviations.length > 0) { + const lastDev = deviations[deviations.length - 1]; + const lastDevTime = timeToMinutes(lastDev.time) + (lastDev.dayOffset || 0) * 24 * 60; + const nextPlanned = sortedDoses.find(d => timeToMinutes(d.time) > (lastDevTime % (24*60))); + if (nextPlanned) { + nextDose = { ...nextPlanned, dayOffset: lastDev.dayOffset }; + } else { + nextDose = { ...sortedDoses[0], dayOffset: (lastDev.dayOffset || 0) + 1 }; + } + } + setDeviations([...deviations, { ...nextDose, isAdditional: false, dayOffset: nextDose.dayOffset || 0 }]); + }; + + const removeDeviation = (index) => { + setDeviations(deviations.filter((_, i) => i !== index)); + }; + + const handleDeviationChange = (index, field, value) => { + const newDeviations = [...deviations]; + newDeviations[index][field] = value; + setDeviations(newDeviations); + }; + + const applySuggestion = () => { + if (!suggestion || !suggestion.dose) return; + setDeviations([...deviations, suggestion]); + setSuggestion(null); + }; + + return { + deviations, + suggestion, + idealProfile, + deviatedProfile, + correctedProfile, + addDeviation, + removeDeviation, + handleDeviationChange, + applySuggestion + }; +}; diff --git a/src/utils/calculations.js b/src/utils/calculations.js new file mode 100644 index 0000000..ed9a491 --- /dev/null +++ b/src/utils/calculations.js @@ -0,0 +1,65 @@ +import { timeToMinutes } from './timeUtils.js'; +import { calculateSingleDoseConcentration } from './pharmacokinetics.js'; + +export const calculateCombinedProfile = ( + doseSchedule, + deviationList = [], + correction = null, + steadyStateConfig, + simulationDays, + pkParams +) => { + const dataPoints = []; + const timeStepHours = 0.25; + const totalHours = (parseInt(simulationDays, 10) || 3) * 24; + const daysToSimulate = Math.min(parseInt(steadyStateConfig.daysOnMedication, 10) || 0, 5); + + for (let t = 0; t <= totalHours; t += timeStepHours) { + let totalLdx = 0; + let totalDamph = 0; + let allDoses = []; + + const maxDayOffset = (parseInt(simulationDays, 10) || 3) - 1; + + for (let day = -daysToSimulate; day <= maxDayOffset; day++) { + const dayOffset = day * 24 * 60; + doseSchedule.forEach(d => { + allDoses.push({ ...d, time: timeToMinutes(d.time) + dayOffset, isPlan: true }); + }); + } + + const currentDeviations = [...deviationList]; + if (correction) { + currentDeviations.push({ ...correction, isAdditional: true }); + } + + currentDeviations.forEach(dev => { + const devTime = timeToMinutes(dev.time) + (dev.dayOffset || 0) * 24 * 60; + if (!dev.isAdditional) { + const closestDoseIndex = allDoses.reduce((closest, dose, index) => { + if (!dose.isPlan) return closest; + const diff = Math.abs(dose.time - devTime); + if (diff <= 60 && diff < closest.minDiff) { + return { index, minDiff: diff }; + } + return closest; + }, { index: -1, minDiff: 61 }).index; + if (closestDoseIndex !== -1) { + allDoses.splice(closestDoseIndex, 1); + } + } + allDoses.push({ ...dev, time: devTime }); + }); + + allDoses.forEach(doseInfo => { + const timeSinceDoseHours = t - doseInfo.time / 60; + const concentrations = calculateSingleDoseConcentration(doseInfo.dose, timeSinceDoseHours, pkParams); + totalLdx += concentrations.ldx; + totalDamph += concentrations.damph; + }); + + dataPoints.push({ timeHours: t, ldx: totalLdx, damph: totalDamph }); + } + + return dataPoints; +}; diff --git a/src/utils/pharmacokinetics.js b/src/utils/pharmacokinetics.js new file mode 100644 index 0000000..ca4fdcc --- /dev/null +++ b/src/utils/pharmacokinetics.js @@ -0,0 +1,29 @@ +import { LDX_TO_DAMPH_CONVERSION_FACTOR } from '../constants/defaults.js'; + +// Pharmacokinetic calculations +export const calculateSingleDoseConcentration = (dose, timeSinceDoseHours, pkParams) => { + const numDose = parseFloat(dose) || 0; + if (timeSinceDoseHours < 0 || numDose <= 0) return { ldx: 0, damph: 0 }; + + const ka_ldx = Math.log(2) / (parseFloat(pkParams.ldx.absorptionRate) || 1); + const k_conv = Math.log(2) / (parseFloat(pkParams.ldx.halfLife) || 1); + const ke_damph = Math.log(2) / (parseFloat(pkParams.damph.halfLife) || 1); + + let ldxConcentration = 0; + if (Math.abs(ka_ldx - k_conv) > 0.0001) { + ldxConcentration = (numDose * ka_ldx / (ka_ldx - k_conv)) * + (Math.exp(-k_conv * timeSinceDoseHours) - Math.exp(-ka_ldx * timeSinceDoseHours)); + } + + let damphConcentration = 0; + if (Math.abs(ka_ldx - ke_damph) > 0.0001 && + Math.abs(k_conv - ke_damph) > 0.0001 && + Math.abs(ka_ldx - k_conv) > 0.0001) { + const term1 = Math.exp(-ke_damph * timeSinceDoseHours) / ((ka_ldx - ke_damph) * (k_conv - ke_damph)); + const term2 = Math.exp(-k_conv * timeSinceDoseHours) / ((ka_ldx - k_conv) * (ke_damph - k_conv)); + const term3 = Math.exp(-ka_ldx * timeSinceDoseHours) / ((k_conv - ka_ldx) * (ke_damph - ka_ldx)); + damphConcentration = LDX_TO_DAMPH_CONVERSION_FACTOR * numDose * ka_ldx * k_conv * (term1 + term2 + term3); + } + + return { ldx: Math.max(0, ldxConcentration), damph: Math.max(0, damphConcentration) }; +}; diff --git a/src/utils/suggestions.js b/src/utils/suggestions.js new file mode 100644 index 0000000..9f6c2cb --- /dev/null +++ b/src/utils/suggestions.js @@ -0,0 +1,69 @@ +import { timeToMinutes } from './timeUtils.js'; +import { calculateCombinedProfile } from './calculations.js'; + +export const generateSuggestion = ( + doses, + deviations, + doseIncrement, + simulationDays, + steadyStateConfig, + pkParams +) => { + if (deviations.length === 0) { + return null; + } + + const lastDeviation = [...deviations].sort((a, b) => + timeToMinutes(a.time) + (a.dayOffset || 0) * 1440 - + (timeToMinutes(b.time) + (b.dayOffset || 0) * 1440) + ).pop(); + + const deviationTimeTotalMinutes = timeToMinutes(lastDeviation.time) + (lastDeviation.dayOffset || 0) * 1440; + + let nextDose = null; + let minDiff = Infinity; + + doses.forEach(d => { + const doseTimeInMinutes = timeToMinutes(d.time); + for (let i = 0; i < (parseInt(simulationDays, 10) || 1); i++) { + const absoluteTime = doseTimeInMinutes + i * 1440; + const diff = absoluteTime - deviationTimeTotalMinutes; + if (diff > 0 && diff < minDiff) { + minDiff = diff; + nextDose = { ...d, dayOffset: i }; + } + } + }); + + if (!nextDose) { + return { text: "Keine passende nächste Dosis für Korrektur gefunden." }; + } + + const numDoseIncrement = parseFloat(doseIncrement) || 1; + const idealProfile = calculateCombinedProfile(doses, [], null, steadyStateConfig, simulationDays, pkParams); + const deviatedProfile = calculateCombinedProfile(doses, deviations, null, steadyStateConfig, simulationDays, pkParams); + + const nextDoseTimeHours = (timeToMinutes(nextDose.time) + (nextDose.dayOffset || 0) * 1440) / 60; + + const idealConcentration = idealProfile.find(p => Math.abs(p.timeHours - nextDoseTimeHours) < 0.1)?.damph || 0; + const deviatedConcentration = deviatedProfile.find(p => Math.abs(p.timeHours - nextDoseTimeHours) < 0.1)?.damph || 0; + const concentrationDifference = idealConcentration - deviatedConcentration; + + if (Math.abs(concentrationDifference) < 0.5) { + return { text: "Keine signifikante Korrektur notwendig." }; + } + + const doseAdjustmentFactor = 0.5; + let doseChange = concentrationDifference / doseAdjustmentFactor; + doseChange = Math.round(doseChange / numDoseIncrement) * numDoseIncrement; + let suggestedDoseValue = (parseFloat(nextDose.dose) || 0) + doseChange; + suggestedDoseValue = Math.max(0, Math.min(70, suggestedDoseValue)); + + return { + time: nextDose.time, + dose: String(suggestedDoseValue), + isAdditional: false, + originalDose: nextDose.dose, + dayOffset: nextDose.dayOffset + }; +}; diff --git a/src/utils/timeUtils.js b/src/utils/timeUtils.js new file mode 100644 index 0000000..62a0969 --- /dev/null +++ b/src/utils/timeUtils.js @@ -0,0 +1,6 @@ +// --- Helper Functions --- +export const timeToMinutes = (timeStr) => { + if (!timeStr || !timeStr.includes(':')) return 0; + const [hours, minutes] = timeStr.split(':').map(Number); + return hours * 60 + minutes; +};