Update App.js, new modular structure
This commit is contained in:
@@ -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",
|
||||
|
||||
555
src/App.js
555
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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
// 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;
|
||||
|
||||
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 {
|
||||
showDayTimeXAxis,
|
||||
chartView,
|
||||
yAxisMin,
|
||||
yAxisMax,
|
||||
simulationDays,
|
||||
displayedDays
|
||||
} = uiSettings;
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<p className="text-gray-600 mt-1">Simulation für Lisdexamfetamin (LDX) und d-Amphetamin (d-amph)</p>
|
||||
</header>
|
||||
|
||||
<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="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 => 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>
|
||||
<DoseSchedule
|
||||
doses={doses}
|
||||
doseIncrement={doseIncrement}
|
||||
onUpdateDoses={(newDoses) => updateState('doses', newDoses)}
|
||||
/>
|
||||
|
||||
<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 => handleDeviationChange(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 => 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">×</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>
|
||||
)}
|
||||
<DeviationList
|
||||
deviations={deviations}
|
||||
doseIncrement={doseIncrement}
|
||||
simulationDays={simulationDays}
|
||||
onAddDeviation={addDeviation}
|
||||
onRemoveDeviation={removeDeviation}
|
||||
onDeviationChange={handleDeviationChange}
|
||||
/>
|
||||
|
||||
<SuggestionPanel
|
||||
suggestion={suggestion}
|
||||
onApplySuggestion={applySuggestion}
|
||||
/>
|
||||
</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="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 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>
|
||||
<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>
|
||||
<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
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<SimulationChart
|
||||
idealProfile={idealProfile}
|
||||
deviatedProfile={deviatedProfile}
|
||||
correctedProfile={correctedProfile}
|
||||
chartView={chartView}
|
||||
showDayTimeXAxis={showDayTimeXAxis}
|
||||
therapeuticRange={therapeuticRange}
|
||||
simulationDays={simulationDays}
|
||||
displayedDays={displayedDays}
|
||||
yAxisMin={yAxisMin}
|
||||
yAxisMax={yAxisMax}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Settings */}
|
||||
<div className="lg:col-span-1 space-y-6 lg:order-3">
|
||||
<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 => 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>
|
||||
<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>
|
||||
<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>
|
||||
<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 => 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>
|
||||
<Settings
|
||||
pkParams={pkParams}
|
||||
therapeuticRange={therapeuticRange}
|
||||
uiSettings={uiSettings}
|
||||
onUpdatePkParams={(key, value) => updateNestedState('pkParams', key, value)}
|
||||
onUpdateTherapeuticRange={(key, value) => updateNestedState('therapeuticRange', key, { [key]: value })}
|
||||
onUpdateUiSetting={updateUiSetting}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default MedPlanAssistant;
|
||||
70
src/components/DeviationList.js
Normal file
70
src/components/DeviationList.js
Normal 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"
|
||||
>
|
||||
×
|
||||
</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;
|
||||
31
src/components/DoseSchedule.js
Normal file
31
src/components/DoseSchedule.js
Normal 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;
|
||||
55
src/components/NumericInput.js
Normal file
55
src/components/NumericInput.js
Normal 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
153
src/components/Settings.js
Normal 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;
|
||||
181
src/components/SimulationChart.js
Normal file
181
src/components/SimulationChart.js
Normal 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;
|
||||
28
src/components/SuggestionPanel.js
Normal file
28
src/components/SuggestionPanel.js
Normal 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
109
src/components/TimeInput.js
Normal 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
29
src/constants/defaults.js
Normal 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
83
src/hooks/useAppState.js
Normal 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
100
src/hooks/useSimulation.js
Normal 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
65
src/utils/calculations.js
Normal 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;
|
||||
};
|
||||
29
src/utils/pharmacokinetics.js
Normal file
29
src/utils/pharmacokinetics.js
Normal 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
69
src/utils/suggestions.js
Normal 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
6
src/utils/timeUtils.js
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user