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

Medikationsplan-Assistent

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

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

Mein Plan

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

Abweichungen vom Plan

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

Was wäre wenn?

- {suggestion.dose ? ( - <> -

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

- - - ) : ( -

{suggestion.text}

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

Erweiterte Einstellungen

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

d-Amphetamin Parameter

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

Lisdexamfetamin Parameter

-
updateNestedState('pkParams', 'ldx', { halfLife: val })} increment={'0.1'} min={0.1} unit="h"/>
-
updateNestedState('pkParams', 'ldx', { absorptionRate: val })} increment={'0.1'} min={0.1} unit="(schneller >)"/>
-
- -
-
-
+ updateNestedState('pkParams', key, value)} + onUpdateTherapeuticRange={(key, value) => updateNestedState('therapeuticRange', key, { [key]: value })} + onUpdateUiSetting={updateUiSetting} + onReset={handleReset} + />
+

Wichtiger Hinweis

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.

- ) + ); }; -export default MedPlanAssistant; +export default MedPlanAssistant; \ No newline at end of file diff --git a/src/components/DeviationList.js b/src/components/DeviationList.js new file mode 100644 index 0000000..fd60637 --- /dev/null +++ b/src/components/DeviationList.js @@ -0,0 +1,70 @@ +import React from 'react'; +import TimeInput from './TimeInput.js'; +import NumericInput from './NumericInput.js'; + +const DeviationList = ({ + deviations, + doseIncrement, + simulationDays, + onAddDeviation, + onRemoveDeviation, + onDeviationChange +}) => { + return ( +
+

Abweichungen vom Plan

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

Mein Plan

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

Erweiterte Einstellungen

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

d-Amphetamin Parameter

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

Lisdexamfetamin Parameter

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

Was wäre wenn?

+ {suggestion.dose ? ( + <> +

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

+ + + ) : ( +

{suggestion.text}

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