Update App.js, new modular structure

This commit is contained in:
2025-10-18 21:19:17 +02:00
parent b666b35fed
commit 0fb168b050
16 changed files with 1118 additions and 453 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "med-plan-assistant", "name": "med-plan-assistant",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",

View File

@@ -1,352 +1,54 @@
import React from 'react'; import React from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer } from 'recharts';
// --- Constants --- // Components
const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v5'; import DoseSchedule from './components/DoseSchedule.js';
const LDX_TO_DAMPH_CONVERSION_FACTOR = 0.2948; import DeviationList from './components/DeviationList.js';
import SuggestionPanel from './components/SuggestionPanel.js';
import SimulationChart from './components/SimulationChart.js';
// --- Helper Functions --- import Settings from './components/Settings.js';
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 (
<div className="relative flex items-center">
<input type="text" value={displayValue} onChange={handleChange} onBlur={handleBlur} placeholder="HH:MM" className="p-2 border rounded-md w-24 text-sm text-center"/>
<button onClick={() => setIsPickerOpen(!isPickerOpen)} className="ml-2 p-2 text-gray-500 hover:text-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.414-1.415L11 9.586V6z" clipRule="evenodd" /></svg>
</button>
{isPickerOpen && (
<div className="absolute top-full mt-2 z-10 bg-white p-4 rounded-lg shadow-xl border w-64">
<div className="text-center text-lg font-bold mb-3">{value}</div>
<div>
<div className="mb-2"><span className="font-semibold">Stunde:</span></div>
<div className="grid grid-cols-6 gap-1">{[...Array(24).keys()].map(h => (<button key={h} onClick={() => handlePickerChange('h', h)} className={`p-1 rounded text-xs ${h === pickerHours ? 'bg-sky-500 text-white' : 'bg-gray-200 hover:bg-sky-200'}`}>{String(h).padStart(2,'0')}</button>))}</div>
</div>
<div className="mt-4">
<div className="mb-2"><span className="font-semibold">Minute:</span></div>
<div className="grid grid-cols-4 gap-1">{[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55].map(m => (<button key={m} onClick={() => handlePickerChange('m', m)} className={`p-1 rounded text-xs ${m === pickerMinutes ? 'bg-sky-500 text-white' : 'bg-gray-200 hover:bg-sky-200'}`}>{String(m).padStart(2,'0')}</button>))}</div>
</div>
<button onClick={() => setIsPickerOpen(false)} className="mt-4 w-full bg-gray-600 text-white py-1 rounded-md text-sm">Schließen</button>
</div>
)}
</div>
);
};
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 (
<div className="flex items-center w-full">
<button onClick={() => updateValue(-1)} className="px-2 py-1 border rounded-l-md bg-gray-100 hover:bg-gray-200 text-lg font-bold">-</button>
<input type="text" value={value} onChange={handleChange} onKeyDown={handleKeyDown} placeholder={placeholder} className="p-2 border-t border-b w-full text-sm text-center"/>
<button onClick={() => updateValue(1)} className="px-2 py-1 border rounded-r-md bg-gray-100 hover:bg-gray-200 text-lg font-bold">+</button>
{unit && <span className="ml-2 text-gray-500 text-sm whitespace-nowrap">{unit}</span>}
</div>
);
};
// Custom Hooks
import { useAppState } from './hooks/useAppState.js';
import { useSimulation } from './hooks/useSimulation.js';
// --- Main Component --- // --- Main Component ---
const MedPlanAssistant = () => { const MedPlanAssistant = () => {
const [appState, setAppState] = React.useState(getDefaultState); const {
const [isLoaded, setIsLoaded] = React.useState(false); appState,
updateState,
updateNestedState,
updateUiSetting,
handleReset
} = useAppState();
React.useEffect(() => { const {
try { pkParams,
const savedState = window.localStorage.getItem(LOCAL_STORAGE_KEY); doses,
if (savedState) { therapeuticRange,
const parsedState = JSON.parse(savedState); doseIncrement,
const defaults = getDefaultState(); uiSettings
setAppState({ } = appState;
...defaults, ...parsedState,
pkParams: {...defaults.pkParams, ...parsedState.pkParams}, const {
uiSettings: {...defaults.uiSettings, ...parsedState.uiSettings}, showDayTimeXAxis,
}); chartView,
} yAxisMin,
} catch (error) { console.error("Failed to load state", error); } yAxisMax,
setIsLoaded(true); simulationDays,
}, []); displayedDays
} = uiSettings;
React.useEffect(() => { const {
if (isLoaded) { deviations,
try { suggestion,
const stateToSave = { idealProfile,
pkParams: appState.pkParams, deviatedProfile,
doses: appState.doses, correctedProfile,
steadyStateConfig: appState.steadyStateConfig, addDeviation,
therapeuticRange: appState.therapeuticRange, removeDeviation,
doseIncrement: appState.doseIncrement, handleDeviationChange,
uiSettings: appState.uiSettings, applySuggestion
}; } = useSimulation(appState);
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);
return ( return (
<div className="bg-gray-100 font-sans p-4 sm:p-6 lg:p-8"> <div className="bg-gray-100 font-sans p-4 sm:p-6 lg:p-8">
@@ -355,134 +57,89 @@ const MedPlanAssistant = () => {
<h1 className="text-3xl md:text-4xl font-bold text-gray-800">Medikationsplan-Assistent</h1> <h1 className="text-3xl md:text-4xl font-bold text-gray-800">Medikationsplan-Assistent</h1>
<p className="text-gray-600 mt-1">Simulation für Lisdexamfetamin (LDX) und d-Amphetamin (d-amph)</p> <p className="text-gray-600 mt-1">Simulation für Lisdexamfetamin (LDX) und d-Amphetamin (d-amph)</p>
</header> </header>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Controls */}
<div className="lg:col-span-1 space-y-6 lg:order-1"> <div className="lg:col-span-1 space-y-6 lg:order-1">
<div className="bg-white p-5 rounded-lg shadow-sm border"> <DoseSchedule
<h2 className="text-xl font-semibold mb-4 text-gray-700">Mein Plan</h2> doses={doses}
{doses.map((dose, index) => ( doseIncrement={doseIncrement}
<div key={index} className="flex items-center space-x-3 mb-3"> onUpdateDoses={(newDoses) => updateState('doses', newDoses)}
<TimeInput value={dose.time} onChange={newTime => updateState('doses', doses.map((d, i) => i === index ? {...d, time: newTime} : d))} /> />
<div className="w-40">
<NumericInput value={dose.dose} onChange={newDose => updateState('doses', doses.map((d, i) => i === index ? {...d, dose: newDose} : d))} increment={doseIncrement} min={0} unit="mg" />
</div>
<span className="text-gray-600 text-sm flex-1">{dose.label}</span>
</div>
))}
</div>
<div className="bg-amber-50 p-5 rounded-lg shadow-sm border border-amber-200"> <DeviationList
<h2 className="text-xl font-semibold mb-4 text-gray-700">Abweichungen vom Plan</h2> deviations={deviations}
{deviations.map((dev, index) => ( doseIncrement={doseIncrement}
<div key={index} className="flex items-center space-x-2 mb-2 p-2 bg-white rounded flex-wrap"> simulationDays={simulationDays}
<select value={dev.dayOffset || 0} onChange={e => handleDeviationChange(index, 'dayOffset', parseInt(e.target.value, 10))} className="p-2 border rounded-md text-sm"> onAddDeviation={addDeviation}
{ [...Array(parseInt(simulationDays, 10) || 1).keys()].map(day => ( onRemoveDeviation={removeDeviation}
<option key={day} value={day}>Tag {day + 1}</option> onDeviationChange={handleDeviationChange}
)) } />
</select>
<TimeInput value={dev.time} onChange={newTime => handleDeviationChange(index, 'time', newTime)} />
<div className="w-32">
<NumericInput value={dev.dose} onChange={newDose => handleDeviationChange(index, 'dose', newDose)} increment={doseIncrement} min={0} unit="mg"/>
</div>
<button onClick={() => removeDeviation(index)} className="text-red-500 hover:text-red-700 font-bold text-lg">&times;</button>
<div className="flex items-center mt-1" title="Mark this if it was an extra dose instead of a replacement for a planned one.">
<input type="checkbox" id={`add_dose_${index}`} checked={dev.isAdditional} onChange={e => handleDeviationChange(index, 'isAdditional', e.target.checked)} className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500" />
<label htmlFor={`add_dose_${index}`} className="ml-2 text-xs text-gray-600">Zusätzlich</label>
</div>
</div>
))}
<button onClick={addDeviation} className="mt-2 w-full bg-amber-500 text-white py-2 rounded-md hover:bg-amber-600 text-sm">Abweichung hinzufügen</button>
</div>
{suggestion && (
<div className="bg-sky-100 border-l-4 border-sky-500 p-4 rounded-r-lg shadow-md">
<h3 className="font-bold text-lg mb-2">Was wäre wenn?</h3>
{suggestion.dose ? (
<>
<p className="text-sm text-sky-800 mb-3">Vorschlag: <span className="font-bold">{suggestion.dose}mg</span> (statt {suggestion.originalDose}mg) um <span className="font-bold">{suggestion.time}</span>.</p>
<button onClick={applySuggestion} className="w-full bg-sky-600 text-white py-2 rounded-md hover:bg-sky-700 text-sm">Vorschlag als Abweichung übernehmen</button>
</>
) : (
<p className="text-sm text-sky-800">{suggestion.text}</p>
)}
</div>
)}
<SuggestionPanel
suggestion={suggestion}
onApplySuggestion={applySuggestion}
/>
</div> </div>
{/* Center Column - Chart */}
<div className="lg:col-span-2 bg-white p-5 rounded-lg shadow-sm border min-h-[600px] flex flex-col lg:order-2"> <div className="lg:col-span-2 bg-white p-5 rounded-lg shadow-sm border min-h-[600px] flex flex-col lg:order-2">
<div className="flex justify-center space-x-2 mb-4"> <div className="flex justify-center space-x-2 mb-4">
<button onClick={() => updateUiSetting('chartView', 'damph')} className={`px-4 py-2 text-sm font-medium rounded-md ${chartView === 'damph' ? 'bg-sky-600 text-white' : 'bg-gray-200 text-gray-700'}`}>d-Amphetamin</button> <button
<button onClick={() => updateUiSetting('chartView', 'ldx')} className={`px-4 py-2 text-sm font-medium rounded-md ${chartView === 'ldx' ? 'bg-sky-600 text-white' : 'bg-gray-200 text-gray-700'}`}>Lisdexamfetamin</button> onClick={() => updateUiSetting('chartView', 'damph')}
<button onClick={() => updateUiSetting('chartView', 'both')} className={`px-4 py-2 text-sm font-medium rounded-md ${chartView === 'both' ? 'bg-sky-600 text-white' : 'bg-gray-200 text-gray-700'}`}>Beide</button> className={`px-4 py-2 text-sm font-medium rounded-md ${chartView === 'damph' ? 'bg-sky-600 text-white' : 'bg-gray-200 text-gray-700'}`}
</div> >
<div className="flex-grow w-full overflow-x-auto"> d-Amphetamin
<div style={{ width: `${chartWidthPercentage}%`, height: '100%', minWidth: '100%' }}> </button>
<ResponsiveContainer width="100%" height="100%"> <button
<LineChart margin={{ top: 20, right: 20, left: 0, bottom: 5 }}> onClick={() => updateUiSetting('chartView', 'ldx')}
<CartesianGrid strokeDasharray="3 3" /> className={`px-4 py-2 text-sm font-medium rounded-md ${chartView === 'ldx' ? 'bg-sky-600 text-white' : 'bg-gray-200 text-gray-700'}`}
<XAxis dataKey="timeHours" type="number" domain={[0, totalHours]} ticks={chartTicks} tickFormatter={(h) => `${h}h`} xAxisId="continuous" /> >
{showDayTimeXAxis && <XAxis dataKey="timeHours" type="number" domain={[0, totalHours]} ticks={chartTicks} tickFormatter={(h) => `${h % 24}h`} xAxisId="daytime" orientation="top" />} Lisdexamfetamin
<YAxis label={{ value: 'Konzentration (ng/ml)', angle: -90, position: 'insideLeft', offset: -10 }} domain={chartDomain} allowDecimals={false} /> </button>
<Tooltip formatter={(value, name) => [`${value.toFixed(1)} ng/ml`, name]} labelFormatter={(label) => `Stunde: ${label}h`}/> <button
<Legend verticalAlign="top" height={36} /> onClick={() => updateUiSetting('chartView', 'both')}
{(chartView === 'damph' || chartView === 'both') && <ReferenceLine y={parseFloat(therapeuticRange.min) || 0} label={{ value: 'Min', position: 'insideTopLeft' }} stroke="green" strokeDasharray="3 3" xAxisId="continuous" />} className={`px-4 py-2 text-sm font-medium rounded-md ${chartView === 'both' ? 'bg-sky-600 text-white' : 'bg-gray-200 text-gray-700'}`}
{(chartView === 'damph' || chartView === 'both') && <ReferenceLine y={parseFloat(therapeuticRange.max) || 0} label={{ value: 'Max', position: 'insideTopLeft' }} stroke="red" strokeDasharray="3 3" xAxisId="continuous" />} >
Beide
{[...Array(parseInt(simulationDays, 10) || 0).keys()].map(day => ( </button>
day > 0 && <ReferenceLine key={day} x={day * 24} stroke="#999" strokeDasharray="5 5" xAxisId="continuous" />
))}
{(chartView === 'damph' || chartView === 'both') && <Line type="monotone" data={idealProfile} dataKey="damph" name="d-Amphetamin (Ideal)" stroke="#3b82f6" strokeWidth={2.5} dot={false} xAxisId="continuous"/>}
{(chartView === 'ldx' || chartView === 'both') && <Line type="monotone" data={idealProfile} dataKey="ldx" name="Lisdexamfetamin (Ideal)" stroke="#8b5cf6" strokeWidth={2} dot={false} strokeDasharray="3 3" xAxisId="continuous"/>}
{deviatedProfile && (chartView === 'damph' || chartView === 'both') && <Line type="monotone" data={deviatedProfile} dataKey="damph" name="d-Amphetamin (Abweichung)" stroke="#f59e0b" strokeWidth={2} strokeDasharray="5 5" dot={false} xAxisId="continuous"/>}
{deviatedProfile && (chartView === 'ldx' || chartView === 'both') && <Line type="monotone" data={deviatedProfile} dataKey="ldx" name="Lisdexamfetamin (Abweichung)" stroke="#f97316" strokeWidth={1.5} strokeDasharray="5 5" dot={false} xAxisId="continuous"/>}
{correctedProfile && (chartView === 'damph' || chartView === 'both') && <Line type="monotone" data={correctedProfile} dataKey="damph" name="d-Amphetamin (Korrektur)" stroke="#10b981" strokeWidth={2.5} strokeDasharray="3 7" dot={false} xAxisId="continuous"/>}
{correctedProfile && (chartView === 'ldx' || chartView === 'both') && <Line type="monotone" data={correctedProfile} dataKey="ldx" name="Lisdexamfetamin (Korrektur)" stroke="#059669" strokeWidth={2} strokeDasharray="3 7" dot={false} xAxisId="continuous"/>}
</LineChart>
</ResponsiveContainer>
</div>
</div> </div>
<SimulationChart
idealProfile={idealProfile}
deviatedProfile={deviatedProfile}
correctedProfile={correctedProfile}
chartView={chartView}
showDayTimeXAxis={showDayTimeXAxis}
therapeuticRange={therapeuticRange}
simulationDays={simulationDays}
displayedDays={displayedDays}
yAxisMin={yAxisMin}
yAxisMax={yAxisMax}
/>
</div> </div>
{/* Right Column - Settings */}
<div className="lg:col-span-1 space-y-6 lg:order-3"> <div className="lg:col-span-1 space-y-6 lg:order-3">
<div className="bg-white p-5 rounded-lg shadow-sm border"> <Settings
<h2 className="text-xl font-semibold mb-4 text-gray-700">Erweiterte Einstellungen</h2> pkParams={pkParams}
<div className="space-y-4 text-sm"> therapeuticRange={therapeuticRange}
<div className="flex items-center"><input type="checkbox" id="showDayTimeXAxis" checked={showDayTimeXAxis} onChange={e => updateUiSetting('showDayTimeXAxis', e.target.checked)} className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500" /><label htmlFor="showDayTimeXAxis" className="ml-3 block font-medium text-gray-600">24h-Zeitachse anzeigen</label></div> uiSettings={uiSettings}
<label className="block font-medium text-gray-600 pt-2">Simulationsdauer</label><div className="w-40"><NumericInput value={simulationDays} onChange={val => updateUiSetting('simulationDays', val)} increment={'1'} min={2} max={7} unit="Tage"/></div> onUpdatePkParams={(key, value) => updateNestedState('pkParams', key, value)}
<label className="block font-medium text-gray-600">Angezeigte Tage</label><div className="w-40"><NumericInput value={displayedDays} onChange={val => updateUiSetting('displayedDays', val)} increment={'1'} min={1} max={parseInt(simulationDays, 10) || 1} unit="Tage"/></div> onUpdateTherapeuticRange={(key, value) => updateNestedState('therapeuticRange', key, { [key]: value })}
<label className="block font-medium text-gray-600 pt-2">Y-Achsen-Bereich</label> onUpdateUiSetting={updateUiSetting}
<div className="flex items-center space-x-2 mt-1"> onReset={handleReset}
<div className="w-32"><NumericInput value={yAxisMin} onChange={val => updateUiSetting('yAxisMin', val)} increment={'5'} min={0} placeholder="Auto" unit="ng/ml"/></div> />
<span className="text-gray-500">-</span>
<div className="w-32"><NumericInput value={yAxisMax} onChange={val => updateUiSetting('yAxisMax', val)} increment={'5'} min={0} placeholder="Auto" unit="ng/ml"/></div>
</div>
<label className="block font-medium text-gray-600">Therapeutischer Bereich</label>
<div className="flex items-center space-x-2 mt-1">
<div className="w-32"><NumericInput value={therapeuticRange.min} onChange={val => updateNestedState('therapeuticRange', 'min', val)} increment={'0.5'} min={0} placeholder="Min" unit="ng/ml"/></div>
<span className="text-gray-500">-</span>
<div className="w-32"><NumericInput value={therapeuticRange.max} onChange={val => updateNestedState('therapeuticRange', 'max', val)} increment={'0.5'} min={0} placeholder="Max" unit="ng/ml"/></div>
</div>
<h3 className="text-lg font-semibold mt-4 pt-4 border-t">d-Amphetamin Parameter</h3>
<div className="w-40"><label className="block font-medium text-gray-600">Halbwertszeit</label><NumericInput value={pkParams.damph.halfLife} onChange={val => updateNestedState('pkParams', 'damph', { halfLife: val })} increment={'0.5'} min={0.1} unit="h"/></div>
<h3 className="text-lg font-semibold mt-4 pt-4 border-t">Lisdexamfetamin Parameter</h3>
<div className="w-40"><label className="block font-medium text-gray-600">Umwandlungs-Halbwertszeit</label><NumericInput value={pkParams.ldx.halfLife} onChange={val => updateNestedState('pkParams', 'ldx', { halfLife: val })} increment={'0.1'} min={0.1} unit="h"/></div>
<div className="w-40"><label className="block font-medium text-gray-600">Absorptionsrate</label><NumericInput value={pkParams.ldx.absorptionRate} onChange={val => updateNestedState('pkParams', 'ldx', { absorptionRate: val })} increment={'0.1'} min={0.1} unit="(schneller >)"/></div>
<div className="pt-4">
<button onClick={handleReset} className="w-full bg-red-600 text-white py-2 rounded-md hover:bg-red-700 text-sm">Alle Einstellungen zurücksetzen</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
<footer className="mt-8 p-4 bg-gray-100 rounded-lg text-sm text-gray-700 border"> <footer className="mt-8 p-4 bg-gray-100 rounded-lg text-sm text-gray-700 border">
<h3 className="font-semibold mb-2">Wichtiger Hinweis</h3> <h3 className="font-semibold mb-2">Wichtiger Hinweis</h3>
<p>Dieses Tool dient ausschließlich zu Illustrations- und Informationszwecken. Es ist kein medizinisches Gerät und ersetzt nicht die Beratung durch einen Arzt oder Apotheker. Alle Berechnungen sind Simulationen, die auf allgemeinen pharmakokinetischen Modellen basieren und von individuellen Faktoren erheblich abweichen können. Bitte konsultiere deinen behandelnden Arzt, bevor du Anpassungen an deiner Medikation vornimmst.</p> <p>Dieses Tool dient ausschließlich zu Illustrations- und Informationszwecken. Es ist kein medizinisches Gerät und ersetzt nicht die Beratung durch einen Arzt oder Apotheker. Alle Berechnungen sind Simulationen, die auf allgemeinen pharmakokinetischen Modellen basieren und von individuellen Faktoren erheblich abweichen können. Bitte konsultiere deinen behandelnden Arzt, bevor du Anpassungen an deiner Medikation vornimmst.</p>
</footer> </footer>
</div> </div>
</div> </div>
) );
}; };
export default MedPlanAssistant; export default MedPlanAssistant;

View File

@@ -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 (
<div className="bg-amber-50 p-5 rounded-lg shadow-sm border border-amber-200">
<h2 className="text-xl font-semibold mb-4 text-gray-700">Abweichungen vom Plan</h2>
{deviations.map((dev, index) => (
<div key={index} className="flex items-center space-x-2 mb-2 p-2 bg-white rounded flex-wrap">
<select
value={dev.dayOffset || 0}
onChange={e => onDeviationChange(index, 'dayOffset', parseInt(e.target.value, 10))}
className="p-2 border rounded-md text-sm"
>
{[...Array(parseInt(simulationDays, 10) || 1).keys()].map(day => (
<option key={day} value={day}>Tag {day + 1}</option>
))}
</select>
<TimeInput
value={dev.time}
onChange={newTime => onDeviationChange(index, 'time', newTime)}
/>
<div className="w-32">
<NumericInput
value={dev.dose}
onChange={newDose => onDeviationChange(index, 'dose', newDose)}
increment={doseIncrement}
min={0}
unit="mg"
/>
</div>
<button
onClick={() => onRemoveDeviation(index)}
className="text-red-500 hover:text-red-700 font-bold text-lg"
>
&times;
</button>
<div className="flex items-center mt-1" title="Mark this if it was an extra dose instead of a replacement for a planned one.">
<input
type="checkbox"
id={`add_dose_${index}`}
checked={dev.isAdditional}
onChange={e => onDeviationChange(index, 'isAdditional', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500"
/>
<label htmlFor={`add_dose_${index}`} className="ml-2 text-xs text-gray-600">
Zusätzlich
</label>
</div>
</div>
))}
<button
onClick={onAddDeviation}
className="mt-2 w-full bg-amber-500 text-white py-2 rounded-md hover:bg-amber-600 text-sm"
>
Abweichung hinzufügen
</button>
</div>
);
};
export default DeviationList;

View File

@@ -0,0 +1,31 @@
import React from 'react';
import TimeInput from './TimeInput.js';
import NumericInput from './NumericInput.js';
const DoseSchedule = ({ doses, doseIncrement, onUpdateDoses }) => {
return (
<div className="bg-white p-5 rounded-lg shadow-sm border">
<h2 className="text-xl font-semibold mb-4 text-gray-700">Mein Plan</h2>
{doses.map((dose, index) => (
<div key={index} className="flex items-center space-x-3 mb-3">
<TimeInput
value={dose.time}
onChange={newTime => onUpdateDoses(doses.map((d, i) => i === index ? {...d, time: newTime} : d))}
/>
<div className="w-40">
<NumericInput
value={dose.dose}
onChange={newDose => onUpdateDoses(doses.map((d, i) => i === index ? {...d, dose: newDose} : d))}
increment={doseIncrement}
min={0}
unit="mg"
/>
</div>
<span className="text-gray-600 text-sm flex-1">{dose.label}</span>
</div>
))}
</div>
);
};
export default DoseSchedule;

View File

@@ -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 (
<div className="flex items-center w-full">
<button
onClick={() => updateValue(-1)}
className="px-2 py-1 border rounded-l-md bg-gray-100 hover:bg-gray-200 text-lg font-bold"
>
-
</button>
<input
type="text"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="p-2 border-t border-b w-full text-sm text-center"
/>
<button
onClick={() => updateValue(1)}
className="px-2 py-1 border rounded-r-md bg-gray-100 hover:bg-gray-200 text-lg font-bold"
>
+
</button>
{unit && <span className="ml-2 text-gray-500 text-sm whitespace-nowrap">{unit}</span>}
</div>
);
};
export default NumericInput;

153
src/components/Settings.js Normal file
View File

@@ -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 (
<div className="bg-white p-5 rounded-lg shadow-sm border">
<h2 className="text-xl font-semibold mb-4 text-gray-700">Erweiterte Einstellungen</h2>
<div className="space-y-4 text-sm">
<div className="flex items-center">
<input
type="checkbox"
id="showDayTimeXAxis"
checked={showDayTimeXAxis}
onChange={e => onUpdateUiSetting('showDayTimeXAxis', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500"
/>
<label htmlFor="showDayTimeXAxis" className="ml-3 block font-medium text-gray-600">
24h-Zeitachse anzeigen
</label>
</div>
<label className="block font-medium text-gray-600 pt-2">Simulationsdauer</label>
<div className="w-40">
<NumericInput
value={simulationDays}
onChange={val => onUpdateUiSetting('simulationDays', val)}
increment={'1'}
min={2}
max={7}
unit="Tage"
/>
</div>
<label className="block font-medium text-gray-600">Angezeigte Tage</label>
<div className="w-40">
<NumericInput
value={displayedDays}
onChange={val => onUpdateUiSetting('displayedDays', val)}
increment={'1'}
min={1}
max={parseInt(simulationDays, 10) || 1}
unit="Tage"
/>
</div>
<label className="block font-medium text-gray-600 pt-2">Y-Achsen-Bereich</label>
<div className="flex items-center space-x-2 mt-1">
<div className="w-32">
<NumericInput
value={yAxisMin}
onChange={val => onUpdateUiSetting('yAxisMin', val)}
increment={'5'}
min={0}
placeholder="Auto"
unit="ng/ml"
/>
</div>
<span className="text-gray-500">-</span>
<div className="w-32">
<NumericInput
value={yAxisMax}
onChange={val => onUpdateUiSetting('yAxisMax', val)}
increment={'5'}
min={0}
placeholder="Auto"
unit="ng/ml"
/>
</div>
</div>
<label className="block font-medium text-gray-600">Therapeutischer Bereich</label>
<div className="flex items-center space-x-2 mt-1">
<div className="w-32">
<NumericInput
value={therapeuticRange.min}
onChange={val => onUpdateTherapeuticRange('min', val)}
increment={'0.5'}
min={0}
placeholder="Min"
unit="ng/ml"
/>
</div>
<span className="text-gray-500">-</span>
<div className="w-32">
<NumericInput
value={therapeuticRange.max}
onChange={val => onUpdateTherapeuticRange('max', val)}
increment={'0.5'}
min={0}
placeholder="Max"
unit="ng/ml"
/>
</div>
</div>
<h3 className="text-lg font-semibold mt-4 pt-4 border-t">d-Amphetamin Parameter</h3>
<div className="w-40">
<label className="block font-medium text-gray-600">Halbwertszeit</label>
<NumericInput
value={pkParams.damph.halfLife}
onChange={val => onUpdatePkParams('damph', { halfLife: val })}
increment={'0.5'}
min={0.1}
unit="h"
/>
</div>
<h3 className="text-lg font-semibold mt-4 pt-4 border-t">Lisdexamfetamin Parameter</h3>
<div className="w-40">
<label className="block font-medium text-gray-600">Umwandlungs-Halbwertszeit</label>
<NumericInput
value={pkParams.ldx.halfLife}
onChange={val => onUpdatePkParams('ldx', { halfLife: val })}
increment={'0.1'}
min={0.1}
unit="h"
/>
</div>
<div className="w-40">
<label className="block font-medium text-gray-600">Absorptionsrate</label>
<NumericInput
value={pkParams.ldx.absorptionRate}
onChange={val => onUpdatePkParams('ldx', { absorptionRate: val })}
increment={'0.1'}
min={0.1}
unit="(schneller >)"
/>
</div>
<div className="pt-4">
<button
onClick={onReset}
className="w-full bg-red-600 text-white py-2 rounded-md hover:bg-red-700 text-sm"
>
Alle Einstellungen zurücksetzen
</button>
</div>
</div>
</div>
);
};
export default Settings;

View File

@@ -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 (
<div className="flex-grow w-full overflow-x-auto">
<div style={{ width: `${chartWidthPercentage}%`, height: '100%', minWidth: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart margin={{ top: 20, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timeHours"
type="number"
domain={[0, totalHours]}
ticks={chartTicks}
tickFormatter={(h) => `${h}h`}
xAxisId="continuous"
/>
{showDayTimeXAxis && (
<XAxis
dataKey="timeHours"
type="number"
domain={[0, totalHours]}
ticks={chartTicks}
tickFormatter={(h) => `${h % 24}h`}
xAxisId="daytime"
orientation="top"
/>
)}
<YAxis
label={{ value: 'Konzentration (ng/ml)', angle: -90, position: 'insideLeft', offset: -10 }}
domain={chartDomain}
allowDecimals={false}
/>
<Tooltip
formatter={(value, name) => [`${value.toFixed(1)} ng/ml`, name]}
labelFormatter={(label) => `Stunde: ${label}h`}
/>
<Legend verticalAlign="top" height={36} />
{(chartView === 'damph' || chartView === 'both') && (
<ReferenceLine
y={parseFloat(therapeuticRange.min) || 0}
label={{ value: 'Min', position: 'insideTopLeft' }}
stroke="green"
strokeDasharray="3 3"
xAxisId="continuous"
/>
)}
{(chartView === 'damph' || chartView === 'both') && (
<ReferenceLine
y={parseFloat(therapeuticRange.max) || 0}
label={{ value: 'Max', position: 'insideTopLeft' }}
stroke="red"
strokeDasharray="3 3"
xAxisId="continuous"
/>
)}
{[...Array(parseInt(simulationDays, 10) || 0).keys()].map(day => (
day > 0 && (
<ReferenceLine
key={day}
x={day * 24}
stroke="#999"
strokeDasharray="5 5"
xAxisId="continuous"
/>
)
))}
{(chartView === 'damph' || chartView === 'both') && (
<Line
type="monotone"
data={idealProfile}
dataKey="damph"
name="d-Amphetamin (Ideal)"
stroke="#3b82f6"
strokeWidth={2.5}
dot={false}
xAxisId="continuous"
/>
)}
{(chartView === 'ldx' || chartView === 'both') && (
<Line
type="monotone"
data={idealProfile}
dataKey="ldx"
name="Lisdexamfetamin (Ideal)"
stroke="#8b5cf6"
strokeWidth={2}
dot={false}
strokeDasharray="3 3"
xAxisId="continuous"
/>
)}
{deviatedProfile && (chartView === 'damph' || chartView === 'both') && (
<Line
type="monotone"
data={deviatedProfile}
dataKey="damph"
name="d-Amphetamin (Abweichung)"
stroke="#f59e0b"
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
xAxisId="continuous"
/>
)}
{deviatedProfile && (chartView === 'ldx' || chartView === 'both') && (
<Line
type="monotone"
data={deviatedProfile}
dataKey="ldx"
name="Lisdexamfetamin (Abweichung)"
stroke="#f97316"
strokeWidth={1.5}
strokeDasharray="5 5"
dot={false}
xAxisId="continuous"
/>
)}
{correctedProfile && (chartView === 'damph' || chartView === 'both') && (
<Line
type="monotone"
data={correctedProfile}
dataKey="damph"
name="d-Amphetamin (Korrektur)"
stroke="#10b981"
strokeWidth={2.5}
strokeDasharray="3 7"
dot={false}
xAxisId="continuous"
/>
)}
{correctedProfile && (chartView === 'ldx' || chartView === 'both') && (
<Line
type="monotone"
data={correctedProfile}
dataKey="ldx"
name="Lisdexamfetamin (Korrektur)"
stroke="#059669"
strokeWidth={2}
strokeDasharray="3 7"
dot={false}
xAxisId="continuous"
/>
)}
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
};
export default SimulationChart;

View File

@@ -0,0 +1,28 @@
import React from 'react';
const SuggestionPanel = ({ suggestion, onApplySuggestion }) => {
if (!suggestion) return null;
return (
<div className="bg-sky-100 border-l-4 border-sky-500 p-4 rounded-r-lg shadow-md">
<h3 className="font-bold text-lg mb-2">Was wäre wenn?</h3>
{suggestion.dose ? (
<>
<p className="text-sm text-sky-800 mb-3">
Vorschlag: <span className="font-bold">{suggestion.dose}mg</span> (statt {suggestion.originalDose}mg) um <span className="font-bold">{suggestion.time}</span>.
</p>
<button
onClick={onApplySuggestion}
className="w-full bg-sky-600 text-white py-2 rounded-md hover:bg-sky-700 text-sm"
>
Vorschlag als Abweichung übernehmen
</button>
</>
) : (
<p className="text-sm text-sky-800">{suggestion.text}</p>
)}
</div>
);
};
export default SuggestionPanel;

109
src/components/TimeInput.js Normal file
View File

@@ -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 (
<div className="relative flex items-center">
<input
type="text"
value={displayValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder="HH:MM"
className="p-2 border rounded-md w-24 text-sm text-center"
/>
<button
onClick={() => setIsPickerOpen(!isPickerOpen)}
className="ml-2 p-2 text-gray-500 hover:text-gray-700"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.414-1.415L11 9.586V6z" clipRule="evenodd" />
</svg>
</button>
{isPickerOpen && (
<div className="absolute top-full mt-2 z-10 bg-white p-4 rounded-lg shadow-xl border w-64">
<div className="text-center text-lg font-bold mb-3">{value}</div>
<div>
<div className="mb-2"><span className="font-semibold">Stunde:</span></div>
<div className="grid grid-cols-6 gap-1">
{[...Array(24).keys()].map(h => (
<button
key={h}
onClick={() => handlePickerChange('h', h)}
className={`p-1 rounded text-xs ${h === pickerHours ? 'bg-sky-500 text-white' : 'bg-gray-200 hover:bg-sky-200'}`}
>
{String(h).padStart(2,'0')}
</button>
))}
</div>
</div>
<div className="mt-4">
<div className="mb-2"><span className="font-semibold">Minute:</span></div>
<div className="grid grid-cols-4 gap-1">
{[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55].map(m => (
<button
key={m}
onClick={() => handlePickerChange('m', m)}
className={`p-1 rounded text-xs ${m === pickerMinutes ? 'bg-sky-500 text-white' : 'bg-gray-200 hover:bg-sky-200'}`}
>
{String(m).padStart(2,'0')}
</button>
))}
</div>
</div>
<button
onClick={() => setIsPickerOpen(false)}
className="mt-4 w-full bg-gray-600 text-white py-1 rounded-md text-sm"
>
Schließen
</button>
</div>
)}
</div>
);
};
export default TimeInput;

29
src/constants/defaults.js Normal file
View File

@@ -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',
}
});

83
src/hooks/useAppState.js Normal file
View File

@@ -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
};
};

100
src/hooks/useSimulation.js Normal file
View File

@@ -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
};
};

65
src/utils/calculations.js Normal file
View File

@@ -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;
};

View File

@@ -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) };
};

69
src/utils/suggestions.js Normal file
View File

@@ -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
};
};

6
src/utils/timeUtils.js Normal file
View File

@@ -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;
};