From 551ba9fd62ed08efb7e5b49a0a19a2d3bf87a464 Mon Sep 17 00:00:00 2001 From: Andreas Weyer Date: Wed, 26 Nov 2025 20:00:39 +0000 Subject: [PATCH] Update migrated js to ts and shadcn --- components.json | 22 ++ package.json | 28 ++- src/{App.test.js => App.test.tsx} | 2 + src/{App.js => App.tsx} | 57 ++--- src/components/DeviationList.js | 71 ------ src/components/DoseSchedule.js | 33 --- src/components/LanguageSelector.js | 19 -- src/components/NumericInput.js | 235 ------------------ src/components/Settings.js | 178 ------------- src/components/SuggestionPanel.js | 26 -- src/components/TimeInput.js | 208 ---------------- src/components/deviation-list.tsx | 106 ++++++++ src/components/dose-schedule.tsx | 38 +++ src/components/language-selector.tsx | 22 ++ src/components/settings.tsx | 178 +++++++++++++ ...imulationChart.js => simulation-chart.tsx} | 23 +- src/components/suggestion-panel.tsx | 34 +++ src/components/ui/button.tsx | 57 +++++ src/components/ui/card.tsx | 76 ++++++ src/components/ui/form-numeric-input.tsx | 201 +++++++++++++++ src/components/ui/form-time-input.tsx | 173 +++++++++++++ src/components/ui/input.tsx | 22 ++ src/components/ui/label.tsx | 24 ++ src/components/ui/popover.tsx | 33 +++ src/components/ui/select.tsx | 157 ++++++++++++ src/components/ui/separator.tsx | 31 +++ src/components/ui/slider.tsx | 26 ++ src/components/ui/switch.tsx | 29 +++ src/components/ui/tooltip.tsx | 30 +++ src/constants/defaults.js | 29 --- src/constants/defaults.ts | 79 ++++++ src/hooks/{useAppState.js => useAppState.ts} | 21 +- src/hooks/{useLanguage.js => useLanguage.ts} | 8 +- .../{useSimulation.js => useSimulation.ts} | 63 +++-- src/index.css | 3 - src/index.js | 17 -- src/index.tsx | 14 ++ src/lib/utils.ts | 7 + src/locales/{de.js => de.ts} | 0 src/locales/{en.js => en.ts} | 0 src/locales/{index.js => index.ts} | 10 +- src/reportWebVitals.js | 13 - src/{setupTests.js => setupTests.ts} | 0 src/styles/global.css | 43 ++++ .../{calculations.js => calculations.ts} | 44 +++- ...harmacokinetics.js => pharmacokinetics.ts} | 13 +- src/utils/{suggestions.js => suggestions.ts} | 54 ++-- src/utils/{timeUtils.js => timeUtils.ts} | 4 +- tailwind.config.js | 53 +++- tsconfig.json | 25 ++ vite.config.ts | 0 51 files changed, 1702 insertions(+), 937 deletions(-) create mode 100644 components.json rename src/{App.test.js => App.test.tsx} (89%) rename src/{App.js => App.tsx} (63%) delete mode 100644 src/components/DeviationList.js delete mode 100644 src/components/DoseSchedule.js delete mode 100644 src/components/LanguageSelector.js delete mode 100644 src/components/NumericInput.js delete mode 100644 src/components/Settings.js delete mode 100644 src/components/SuggestionPanel.js delete mode 100644 src/components/TimeInput.js create mode 100644 src/components/deviation-list.tsx create mode 100644 src/components/dose-schedule.tsx create mode 100644 src/components/language-selector.tsx create mode 100644 src/components/settings.tsx rename src/components/{SimulationChart.js => simulation-chart.tsx} (89%) create mode 100644 src/components/suggestion-panel.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/form-numeric-input.tsx create mode 100644 src/components/ui/form-time-input.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/slider.tsx create mode 100644 src/components/ui/switch.tsx create mode 100644 src/components/ui/tooltip.tsx delete mode 100644 src/constants/defaults.js create mode 100644 src/constants/defaults.ts rename src/hooks/{useAppState.js => useAppState.ts} (77%) rename src/hooks/{useLanguage.js => useLanguage.ts} (71%) rename src/hooks/{useSimulation.js => useSimulation.ts} (54%) delete mode 100644 src/index.css delete mode 100644 src/index.js create mode 100644 src/index.tsx create mode 100644 src/lib/utils.ts rename src/locales/{de.js => de.ts} (100%) rename src/locales/{en.js => en.ts} (100%) rename src/locales/{index.js => index.ts} (62%) delete mode 100644 src/reportWebVitals.js rename src/{setupTests.js => setupTests.ts} (100%) create mode 100644 src/styles/global.css rename src/utils/{calculations.js => calculations.ts} (60%) rename src/utils/{pharmacokinetics.js => pharmacokinetics.ts} (81%) rename src/utils/{suggestions.js => suggestions.ts} (58%) rename src/utils/{timeUtils.js => timeUtils.ts} (62%) create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/components.json b/components.json new file mode 100644 index 0000000..a0b3ccb --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "./src/components", + "utils": "./src/lib/utils", + "ui": "./src/components/ui", + "lib": "./src/lib", + "hooks": "./src/hooks" + }, + "registries": {} +} diff --git a/package.json b/package.json index 45e4b2f..3f848fe 100644 --- a/package.json +++ b/package.json @@ -3,16 +3,34 @@ "version": "0.1.1", "private": true, "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.8", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^13.5.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.554.0", "npx": "^10.2.2", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-hook-form": "^7.66.1", + "react-is": "^19.2.0", "react-scripts": "5.0.1", "recharts": "^3.3.0", - "web-vitals": "^2.1.4" + "tailwind-merge": "^3.4.0", + "tailwindcss-animate": "^1.0.7", + "tw-animate-css": "^1.4.0", + "web-vitals": "^2.1.4", + "zod": "^4.1.13" }, "scripts": { "start": "cross-env HOST=0.0.0.0 BROWSER=none CHOKIDAR_USEPOLLING=true FAST_REFRESH=false react-scripts start", @@ -41,11 +59,15 @@ }, "devDependencies": { "@hint/configuration-web-recommended": "^8.2.24", - "autoprefixer": "^10.4.21", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "autoprefixer": "^10.4.22", "cross-env": "^10.1.0", "hint": "^7.1.13", "postcss": "^8.5.6", "puppeteer": "^24.27.0", - "tailwindcss": "^3.4.18" + "tailwindcss": "^3.4.18", + "typescript": "^4.9.5" } } diff --git a/src/App.test.js b/src/App.test.tsx similarity index 89% rename from src/App.test.js rename to src/App.test.tsx index 1f03afe..ece4ad8 100644 --- a/src/App.test.js +++ b/src/App.test.tsx @@ -1,8 +1,10 @@ import { render, screen } from '@testing-library/react'; import App from './App'; +// @ts-ignore test('renders learn react link', () => { render(); const linkElement = screen.getByText(/learn react/i); + // @ts-ignore expect(linkElement).toBeInTheDocument(); }); diff --git a/src/App.js b/src/App.tsx similarity index 63% rename from src/App.js rename to src/App.tsx index 3200a08..450622c 100644 --- a/src/App.js +++ b/src/App.tsx @@ -1,17 +1,18 @@ import React from 'react'; // 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'; -import LanguageSelector from './components/LanguageSelector.js'; +import DoseSchedule from './components/dose-schedule'; +import DeviationList from './components/deviation-list'; +import SuggestionPanel from './components/suggestion-panel'; +import SimulationChart from './components/simulation-chart'; +import Settings from './components/settings'; +import LanguageSelector from './components/language-selector'; +import { Button } from './components/ui/button'; // Custom Hooks -import { useAppState } from './hooks/useAppState.js'; -import { useSimulation } from './hooks/useSimulation.js'; -import { useLanguage } from './hooks/useLanguage.js'; +import { useAppState } from './hooks/useAppState'; +import { useSimulation } from './hooks/useSimulation'; +import { useLanguage } from './hooks/useLanguage'; // --- Main Component --- const MedPlanAssistant = () => { @@ -55,13 +56,13 @@ const MedPlanAssistant = () => { } = useSimulation(appState); return ( -
+
-

{t.appTitle}

-

{t.appSubtitle}

+

{t.appTitle}

+

{t.appSubtitle}

@@ -70,26 +71,26 @@ const MedPlanAssistant = () => {
{/* Both Columns - Chart */} -
-
- - - +
{ updateState('doses', newDoses)} + onUpdateDoses={(newDoses: any) => updateState('doses', newDoses)} t={t} /> @@ -139,8 +140,8 @@ const MedPlanAssistant = () => { pkParams={pkParams} therapeuticRange={therapeuticRange} uiSettings={uiSettings} - onUpdatePkParams={(key, value) => updateNestedState('pkParams', key, value)} - onUpdateTherapeuticRange={(key, value) => updateNestedState('therapeuticRange', key, value)} + onUpdatePkParams={(key: any, value: any) => updateNestedState('pkParams', key, value)} + onUpdateTherapeuticRange={(key: any, value: any) => updateNestedState('therapeuticRange', key, value)} onUpdateUiSetting={updateUiSetting} onReset={handleReset} t={t} @@ -149,8 +150,8 @@ const MedPlanAssistant = () => {
-
-

{t.importantNote}

+
+

{t.importantNote}

{t.disclaimer}

diff --git a/src/components/DeviationList.js b/src/components/DeviationList.js deleted file mode 100644 index 4afec3e..0000000 --- a/src/components/DeviationList.js +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; -import TimeInput from './TimeInput.js'; -import NumericInput from './NumericInput.js'; - -const DeviationList = ({ - deviations, - doseIncrement, - simulationDays, - onAddDeviation, - onRemoveDeviation, - onDeviationChange, - t -}) => { - return ( -
-

{t.deviationsFromPlan}

- {deviations.map((dev, index) => ( -
- - onDeviationChange(index, 'time', newTime)} - errorMessage={t.errorTimeRequired} - /> -
- onDeviationChange(index, 'dose', newDose)} - increment={doseIncrement} - min={0} - unit={t.mg} - errorMessage={t.fieldRequired} - /> -
- -
- 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 deleted file mode 100644 index f5a1294..0000000 --- a/src/components/DoseSchedule.js +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import TimeInput from './TimeInput.js'; -import NumericInput from './NumericInput.js'; - -const DoseSchedule = ({ doses, doseIncrement, onUpdateDoses, t }) => { - return ( -
-

{t.myPlan}

- {doses.map((dose, index) => ( -
- onUpdateDoses(doses.map((d, i) => i === index ? {...d, time: newTime} : d))} - errorMessage={t.errorTimeRequired} - /> -
- onUpdateDoses(doses.map((d, i) => i === index ? {...d, dose: newDose} : d))} - increment={doseIncrement} - min={0} - unit={t.mg} - errorMessage={t.fieldRequired} - /> -
- {t[dose.label] || dose.label} -
- ))} -
- ); -}; - -export default DoseSchedule; diff --git a/src/components/LanguageSelector.js b/src/components/LanguageSelector.js deleted file mode 100644 index cb6f292..0000000 --- a/src/components/LanguageSelector.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -const LanguageSelector = ({ currentLanguage, onLanguageChange, t }) => { - return ( -
- - -
- ); -}; - -export default LanguageSelector; diff --git a/src/components/NumericInput.js b/src/components/NumericInput.js deleted file mode 100644 index 13e04e3..0000000 --- a/src/components/NumericInput.js +++ /dev/null @@ -1,235 +0,0 @@ -import React from 'react'; - -const NumericInput = ({ - value, - onChange, - increment, - min = -Infinity, - max = Infinity, - placeholder, - unit, - align = 'right', // 'left', 'center', 'right' - allowEmpty = false, // Allow empty value (e.g., for "Auto" mode) - clearButton = false, // Show clear button (with allowEmpty=true) - clearButtonText, // Custom text or element for clear button - clearButtonTitle, // Custom title for clear button - errorMessage = 'This field is required' // Error message for mandatory empty fields -}) => { - const [hasError, setHasError] = React.useState(false); - const [showErrorTooltip, setShowErrorTooltip] = React.useState(false); - const containerRef = React.useRef(null); - const showClearButton = clearButton && allowEmpty; - - // Determine decimal places based on increment - const getDecimalPlaces = () => { - const inc = String(increment || '1'); - const decimalIndex = inc.indexOf('.'); - if (decimalIndex === -1) return 0; - return inc.length - decimalIndex - 1; - }; - - const decimalPlaces = getDecimalPlaces(); - - // Format value for display - const formatValue = (val) => { - const num = Number(val); - if (isNaN(num)) return val; - return num.toFixed(decimalPlaces); - }; - - const updateValue = (direction) => { - const numIncrement = parseFloat(increment) || 1; - let numValue = Number(value); - - // If value is empty/Auto, treat as 0 - if (isNaN(numValue)) { - numValue = 0; - } - - numValue += direction * numIncrement; - numValue = Math.max(min, numValue); - numValue = Math.min(max, numValue); - const finalValue = formatValue(numValue); - setHasError(false); // Clear error when using buttons - 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; - // Only allow valid numbers or empty string - if (val === '' || /^-?\d*\.?\d*$/.test(val)) { - // Prevent object values - if (typeof val === 'object') return; - setHasError(false); // Clear error on input - onChange(val); - } - }; - - const handleBlur = (e) => { - const val = e.target.value; - - // Check if field is empty and not allowed to be empty - if (val === '' && !allowEmpty) { - setHasError(true); - return; - } - - if (val !== '' && !isNaN(Number(val))) { - // Format the value when user finishes editing - setHasError(false); - onChange(formatValue(val)); - } - }; - - // Get alignment class - const getAlignmentClass = () => { - switch (align) { - case 'left': return 'text-left'; - case 'center': return 'text-center'; - case 'right': return 'text-right'; - default: return 'text-right'; - } - }; - - const handleClear = () => { - setHasError(false); - setShowErrorTooltip(false); - onChange(''); - }; - - const handleFocus = () => { - if (hasError) setShowErrorTooltip(true); - }; - - const handleInputBlurWrapper = (e) => { - handleBlur(e); - // Small delay to allow error state to be set before hiding tooltip - setTimeout(() => { - setShowErrorTooltip(false); - }, 100); - }; - - const handleMouseEnter = () => { - if (hasError) setShowErrorTooltip(true); - }; - - const handleMouseLeave = () => { - setShowErrorTooltip(false); - }; - - React.useEffect(() => { - // Close tooltip when clicking outside - const handleClickOutside = (event) => { - if (containerRef.current && !containerRef.current.contains(event.target)) { - setShowErrorTooltip(false); - } - }; - - if (showErrorTooltip) { - document.addEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [showErrorTooltip]); - - // Calculate tooltip position to prevent overflow - const [tooltipPosition, setTooltipPosition] = React.useState({ top: true, left: true }); - - React.useEffect(() => { - if (showErrorTooltip && containerRef.current) { - const rect = containerRef.current.getBoundingClientRect(); - const tooltipWidth = 300; // Approximate width - const tooltipHeight = 60; // Approximate height - - const spaceRight = window.innerWidth - rect.right; - const spaceBottom = window.innerHeight - rect.bottom; - - setTooltipPosition({ - top: spaceBottom < tooltipHeight + 10, - left: spaceRight < tooltipWidth - }); - } - }, [showErrorTooltip]); - - return ( -
-
-
- - - - {showClearButton && ( - )} -
- {unit && {unit}} -
- {hasError && showErrorTooltip && ( -
-
- - - - -
- {errorMessage} -
- )} -
- ); -}; - -export default NumericInput; diff --git a/src/components/Settings.js b/src/components/Settings.js deleted file mode 100644 index 475e6cf..0000000 --- a/src/components/Settings.js +++ /dev/null @@ -1,178 +0,0 @@ -import React from 'react'; -import NumericInput from './NumericInput.js'; - -const Settings = ({ - pkParams, - therapeuticRange, - uiSettings, - onUpdatePkParams, - onUpdateTherapeuticRange, - onUpdateUiSetting, - onReset, - t -}) => { - const { showDayTimeOnXAxis, yAxisMin, yAxisMax, simulationDays, displayedDays } = uiSettings; - // https://www.svgrepo.com/svg/509013/actual-size - const clearButtonSVG = ( ); - - return ( -
-

{t.advancedSettings}

-
-
- onUpdateUiSetting('showDayTimeOnXAxis', 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} - placeholder="#" - unit={t.days} - errorMessage={t.errorNumberRequired} - /> -
- -
- - onUpdateUiSetting('displayedDays', val)} - increment={'1'} - min={1} - max={parseInt(simulationDays, 10) || 1} - placeholder="#" - unit={t.days} - errorMessage={t.errorNumberRequired} - /> -
- -
- -
-
- onUpdateUiSetting('yAxisMin', val)} - increment={'5'} - min={0} - placeholder={t.auto} - allowEmpty={true} - clearButton={true} - clearButtonText={clearButtonSVG || t.yAxisRangeAutoButton} - clearButtonTitle={t.yAxisRangeAutoButtonTitle} - /> -
- - -
- onUpdateUiSetting('yAxisMax', val)} - increment={'5'} - min={0} - placeholder={t.auto} - unit="ng/ml" - allowEmpty={true} - clearButton={true} - clearButtonText={clearButtonSVG || t.yAxisRangeAutoButton} - clearButtonTitle={t.yAxisRangeAutoButtonTitle} - /> -
-
-
- -
- -
-
- onUpdateTherapeuticRange('min', val)} - increment={'0.5'} - min={0} - placeholder={t.min} - errorMessage={t.errorNumberRequired} - /> -
- - -
- onUpdateTherapeuticRange('max', val)} - increment={'0.5'} - min={0} - placeholder={t.max} - unit="ng/ml" - errorMessage={t.errorNumberRequired} - /> -
-
-
- -

{t.dAmphetamineParameters}

-
- - onUpdatePkParams('damph', { halfLife: val })} - increment={'0.5'} - min={0.1} - placeholder="#.#" - unit="h" - errorMessage={t.errorNumberRequired} - /> -
- -

{t.lisdexamfetamineParameters}

-
- - onUpdatePkParams('ldx', { halfLife: val })} - increment={'0.1'} - min={0.1} - placeholder="#.#" - unit="h" - errorMessage={t.errorNumberRequired} - /> -
-
- - onUpdatePkParams('ldx', { absorptionRate: val })} - increment={'0.1'} - min={0.1} - placeholder="#.#" - unit={t.faster} - errorMessage={t.errorNumberRequired} - /> -
- -
- -
-
-
- ); -}; - -export default Settings; diff --git a/src/components/SuggestionPanel.js b/src/components/SuggestionPanel.js deleted file mode 100644 index 86f1a23..0000000 --- a/src/components/SuggestionPanel.js +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; - -const SuggestionPanel = ({ suggestion, onApplySuggestion, t }) => { - if (!suggestion) return null; - - return ( -
-

{t.whatIf}

- {suggestion.dose ? ( - <> -

- {t.suggestion}: {suggestion.dose}{t.mg} ({t.instead} {suggestion.originalDose}{t.mg}) {t.at} {suggestion.time}. -

- - - ) : ( -

{suggestion.text}

- )} -
- ); -};export default SuggestionPanel; diff --git a/src/components/TimeInput.js b/src/components/TimeInput.js deleted file mode 100644 index 431f1ca..0000000 --- a/src/components/TimeInput.js +++ /dev/null @@ -1,208 +0,0 @@ -import React from 'react'; - -const TimeInput = ({ value, onChange, errorMessage = 'Time is required' }) => { - const [displayValue, setDisplayValue] = React.useState(value); - const [isPickerOpen, setIsPickerOpen] = React.useState(false); - const [hasError, setHasError] = React.useState(false); - const [showErrorTooltip, setShowErrorTooltip] = React.useState(false); - const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number); - const pickerRef = React.useRef(null); - const containerRef = React.useRef(null); - - React.useEffect(() => { - setDisplayValue(value); - }, [value]); - - React.useEffect(() => { - // Close the picker when clicking outside - const handleClickOutside = (event) => { - if (pickerRef.current && !pickerRef.current.contains(event.target)) { - setIsPickerOpen(false); - } - if (containerRef.current && !containerRef.current.contains(event.target)) { - setShowErrorTooltip(false); - } - }; - - if (isPickerOpen || showErrorTooltip) { - document.addEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isPickerOpen, showErrorTooltip]); - - const handleBlur = (e) => { - const inputValue = e.target.value.trim(); - - // Check if field is empty - if (inputValue === '') { - setHasError(true); - // Small delay before hiding tooltip - setTimeout(() => setShowErrorTooltip(false), 100); - return; - } - - let input = inputValue.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}`; - setHasError(false); - setShowErrorTooltip(false); - setDisplayValue(formattedTime); - onChange(formattedTime); - }; - - const handleChange = (e) => { - setHasError(false); // Clear error on input - setShowErrorTooltip(false); - 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')}`; - setHasError(false); // Clear error when using picker - setShowErrorTooltip(false); - onChange(formattedTime); - }; - - const handleFocus = () => { - if (hasError) setShowErrorTooltip(true); - }; - - const handleMouseEnter = () => { - if (hasError) setShowErrorTooltip(true); - }; - - const handleMouseLeave = () => { - setShowErrorTooltip(false); - }; - - const [tooltipPosition, setTooltipPosition] = React.useState({ top: true, left: true }); - - React.useEffect(() => { - if (showErrorTooltip && containerRef.current) { - const rect = containerRef.current.getBoundingClientRect(); - const tooltipWidth = 300; // Approximate width - const tooltipHeight = 60; // Approximate height - - const spaceRight = window.innerWidth - rect.right; - const spaceBottom = window.innerHeight - rect.bottom; - - setTooltipPosition({ - top: spaceBottom < tooltipHeight + 10, - left: spaceRight < tooltipWidth - }); - } - }, [showErrorTooltip]); - - - return ( -
-
-
- -
- - {isPickerOpen && ( -
-
{value}
-
-
Hour:
-
- {[...Array(24).keys()].map(h => ( - - ))} -
-
-
-
Minute:
-
- {[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55].map(m => ( - - ))} -
-
- -
- )} - {hasError && showErrorTooltip && ( -
-
- - - - -
- {errorMessage} -
- )} -
-
- ); -}; - -export default TimeInput; diff --git a/src/components/deviation-list.tsx b/src/components/deviation-list.tsx new file mode 100644 index 0000000..481054e --- /dev/null +++ b/src/components/deviation-list.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { FormTimeInput } from './ui/form-time-input'; +import { FormNumericInput } from './ui/form-numeric-input'; +import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; +import { Button } from './ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { Switch } from './ui/switch'; +import { Label } from './ui/label'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; +import { Trash2, X } from 'lucide-react'; + +const DeviationList = ({ + deviations, + doseIncrement, + simulationDays, + onAddDeviation, + onRemoveDeviation, + onDeviationChange, + t +}: any) => { + return ( + + + {t.deviationsFromPlan} + + + {deviations.map((dev: any, index: number) => ( +
+ + + onDeviationChange(index, 'time', newTime)} + required={true} + errorMessage={t.timeRequired || 'Time is required'} + /> + + onDeviationChange(index, 'dose', newDose)} + increment={doseIncrement} + min={0} + unit={t.mg} + required={true} + errorMessage={t.doseRequired || 'Dose is required'} + /> + + + + + + +
+ onDeviationChange(index, 'isAdditional', checked)} + /> + +
+
+ +

{t.additionalTooltip}

+
+
+
+
+ ))} + + +
+
+ ); +}; + +export default DeviationList; diff --git a/src/components/dose-schedule.tsx b/src/components/dose-schedule.tsx new file mode 100644 index 0000000..971b2b9 --- /dev/null +++ b/src/components/dose-schedule.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { FormTimeInput } from './ui/form-time-input'; +import { FormNumericInput } from './ui/form-numeric-input'; +import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; + +const DoseSchedule = ({ doses, doseIncrement, onUpdateDoses, t }: any) => { + return ( + + + {t.myPlan} + + + {doses.map((dose: any, index: number) => ( +
+ onUpdateDoses(doses.map((d: any, i: number) => i === index ? {...d, time: newTime} : d))} + required={true} + errorMessage={t.timeRequired || 'Time is required'} + /> + onUpdateDoses(doses.map((d: any, i: number) => i === index ? {...d, dose: newDose} : d))} + increment={doseIncrement} + min={0} + unit={t.mg} + required={true} + errorMessage={t.doseRequired || 'Dose is required'} + /> + {t[dose.label] || dose.label} +
+ ))} +
+
+ ); +}; + +export default DoseSchedule; diff --git a/src/components/language-selector.tsx b/src/components/language-selector.tsx new file mode 100644 index 0000000..d601a1b --- /dev/null +++ b/src/components/language-selector.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { Label } from './ui/label'; + +const LanguageSelector = ({ currentLanguage, onLanguageChange, t }: any) => { + return ( +
+ + +
+ ); +}; + +export default LanguageSelector; diff --git a/src/components/settings.tsx b/src/components/settings.tsx new file mode 100644 index 0000000..a7e2296 --- /dev/null +++ b/src/components/settings.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { FormNumericInput } from './ui/form-numeric-input'; +import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; +import { Button } from './ui/button'; +import { Switch } from './ui/switch'; +import { Label } from './ui/label'; +import { Separator } from './ui/separator'; + +const Settings = ({ + pkParams, + therapeuticRange, + uiSettings, + onUpdatePkParams, + onUpdateTherapeuticRange, + onUpdateUiSetting, + onReset, + t +}: any) => { + const { showDayTimeOnXAxis, yAxisMin, yAxisMax, simulationDays, displayedDays } = uiSettings; + + return ( + + + {t.advancedSettings} + + +
+ + onUpdateUiSetting('showDayTimeOnXAxis', checked)} + /> +
+ +
+ + onUpdateUiSetting('simulationDays', val)} + increment={1} + min={2} + max={7} + unit={t.days} + required={true} + errorMessage={t.simulationDaysRequired || 'Simulation days is required'} + /> +
+ +
+ + onUpdateUiSetting('displayedDays', val)} + increment={1} + min={1} + max={parseInt(simulationDays, 10) || 1} + unit={t.days} + required={true} + errorMessage={t.displayedDaysRequired || 'Displayed days is required'} + /> +
+ +
+ +
+ onUpdateUiSetting('yAxisMin', val)} + increment={5} + min={0} + placeholder={t.auto} + allowEmpty={true} + clearButton={true} + /> + - + onUpdateUiSetting('yAxisMax', val)} + increment={5} + min={0} + placeholder={t.auto} + unit="ng/ml" + allowEmpty={true} + clearButton={true} + /> +
+
+ +
+ +
+ onUpdateTherapeuticRange('min', val)} + increment={0.5} + min={0} + placeholder={t.min} + required={true} + errorMessage={t.therapeuticRangeMinRequired || 'Minimum therapeutic range is required'} + /> + - + onUpdateTherapeuticRange('max', val)} + increment={0.5} + min={0} + placeholder={t.max} + unit="ng/ml" + required={true} + errorMessage={t.therapeuticRangeMaxRequired || 'Maximum therapeutic range is required'} + /> +
+
+ + + +

{t.dAmphetamineParameters}

+
+ + onUpdatePkParams('damph', { halfLife: val })} + increment={0.5} + min={0.1} + unit="h" + required={true} + errorMessage={t.halfLifeRequired || 'Half-life is required'} + /> +
+ + + +

{t.lisdexamfetamineParameters}

+
+ + onUpdatePkParams('ldx', { halfLife: val })} + increment={0.1} + min={0.1} + unit="h" + required={true} + errorMessage={t.conversionHalfLifeRequired || 'Conversion half-life is required'} + /> +
+ +
+ + onUpdatePkParams('ldx', { absorptionRate: val })} + increment={0.1} + min={0.1} + unit={t.faster} + required={true} + errorMessage={t.absorptionRateRequired || 'Absorption rate is required'} + /> +
+ + + + +
+
+ ); +}; + +export default Settings; diff --git a/src/components/SimulationChart.js b/src/components/simulation-chart.tsx similarity index 89% rename from src/components/SimulationChart.js rename to src/components/simulation-chart.tsx index 6f1637b..40b5035 100644 --- a/src/components/SimulationChart.js +++ b/src/components/simulation-chart.tsx @@ -13,12 +13,11 @@ const SimulationChart = ({ yAxisMin, yAxisMax, t -}) => { +}: any) => { const totalHours = (parseInt(simulationDays, 10) || 3) * 24; - const chartTicks = Array.from({length: Math.floor(totalHours / 6) + 1}, (_, i) => i * 6); - // Generate ticks for 24h repeating axis (every 6 hours across all days) - const dayTimeTicks = React.useMemo(() => { + // Generate ticks for continuous time axis (every 6 hours) + const chartTicks = React.useMemo(() => { const ticks = []; for (let i = 0; i <= totalHours; i += 6) { ticks.push(i); @@ -46,17 +45,25 @@ const SimulationChart = ({ dataKey="timeHours" type="number" domain={[0, totalHours]} - ticks={showDayTimeOnXAxis ? dayTimeTicks : chartTicks} - tickFormatter={(h) => `${showDayTimeOnXAxis ? h % 24 : h}${t.hour}`} + ticks={chartTicks} + tickFormatter={(h) => { + if (showDayTimeOnXAxis) { + // Show 24h repeating format (0-23h) + return `${h % 24}${t.hour}`; + } else { + // Show continuous time (0, 6, 12, 18, 24, 30, 36, ...) + return `${h}${t.hour}`; + } + }} xAxisId="hours" /> [`${value.toFixed(1)} ${t.ngml}`, name]} + formatter={(value: any, name) => [`${typeof value === 'number' ? value.toFixed(1) : value} ${t.ngml}`, name]} labelFormatter={(label) => `${t.hour.replace('h', 'Hour')}: ${label}${t.hour}`} /> diff --git a/src/components/suggestion-panel.tsx b/src/components/suggestion-panel.tsx new file mode 100644 index 0000000..0042e9f --- /dev/null +++ b/src/components/suggestion-panel.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; +import { Button } from './ui/button'; + +const SuggestionPanel = ({ suggestion, onApplySuggestion, t }: any) => { + if (!suggestion) return null; + + return ( + + + {t.whatIf} + + + {suggestion.dose ? ( +
+

+ {t.suggestion}: {suggestion.dose}{t.mg} ({t.instead} {suggestion.originalDose}{t.mg}) {t.at} {suggestion.time}. +

+ +
+ ) : ( +

{suggestion.text}

+ )} +
+
+ ); +}; + +export default SuggestionPanel; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..dcfee0c --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "../../lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..61e4170 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "../../lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/form-numeric-input.tsx b/src/components/ui/form-numeric-input.tsx new file mode 100644 index 0000000..5c13b6d --- /dev/null +++ b/src/components/ui/form-numeric-input.tsx @@ -0,0 +1,201 @@ +import * as React from "react" +import { Minus, Plus, X } from "lucide-react" +import { Button } from "./button" +import { Input } from "./input" +import { cn } from "../../lib/utils" + +interface NumericInputProps extends Omit, 'onChange' | 'value'> { + value: string | number + onChange: (value: string) => void + increment?: number | string + min?: number + max?: number + unit?: string + align?: 'left' | 'center' | 'right' + allowEmpty?: boolean + clearButton?: boolean + error?: boolean + required?: boolean + errorMessage?: string +} + +const FormNumericInput = React.forwardRef( + ({ + value, + onChange, + increment = 1, + min = -Infinity, + max = Infinity, + unit, + align = 'right', + allowEmpty = false, + clearButton = false, + error = false, + required = false, + errorMessage = 'This field is required', + className, + ...props + }, ref) => { + const [showError, setShowError] = React.useState(false) + const [touched, setTouched] = React.useState(false) + const containerRef = React.useRef(null) + + // Check if value is invalid (check validity regardless of touch state) + const isInvalid = required && !allowEmpty && (value === '' || value === null || value === undefined) + const hasError = error || isInvalid + + // Check validity on mount and when value changes + React.useEffect(() => { + if (isInvalid && touched) { + setShowError(true) + } else if (!isInvalid) { + setShowError(false) + } + }, [isInvalid, touched]) + // Determine decimal places based on increment + const getDecimalPlaces = () => { + const inc = String(increment || '1') + const decimalIndex = inc.indexOf('.') + if (decimalIndex === -1) return 0 + return inc.length - decimalIndex - 1 + } + + const decimalPlaces = getDecimalPlaces() + + // Format value for display + const formatValue = (val: string | number): string => { + const num = Number(val) + if (isNaN(num)) return String(val) + return num.toFixed(decimalPlaces) + } + + const updateValue = (direction: number) => { + const numIncrement = parseFloat(String(increment)) || 1 + let numValue = Number(value) + + if (isNaN(numValue)) { + numValue = 0 + } + + numValue += direction * numIncrement + numValue = Math.max(min, numValue) + numValue = Math.min(max, numValue) + onChange(formatValue(numValue)) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault() + updateValue(e.key === 'ArrowUp' ? 1 : -1) + } + } + + const handleChange = (e: React.ChangeEvent) => { + const val = e.target.value + if (val === '' || /^-?\d*\.?\d*$/.test(val)) { + onChange(val) + } + } + + const handleBlur = (e: React.FocusEvent) => { + const val = e.target.value + setTouched(true) + setShowError(false) + + if (val === '' && !allowEmpty) { + return + } + + if (val !== '' && !isNaN(Number(val))) { + onChange(formatValue(val)) + } + } + + const handleFocus = () => { + setShowError(hasError) + } + + const getAlignmentClass = () => { + switch (align) { + case 'left': return 'text-left' + case 'center': return 'text-center' + case 'right': return 'text-right' + default: return 'text-right' + } + } + + return ( +
+
+ + + {clearButton && allowEmpty ? ( + + ) : ( + + )} +
+ {unit && {unit}} + {hasError && showError && ( +
+ {errorMessage} +
+ )} +
+ ) + } +) + +FormNumericInput.displayName = "FormNumericInput" + +export { FormNumericInput } diff --git a/src/components/ui/form-time-input.tsx b/src/components/ui/form-time-input.tsx new file mode 100644 index 0000000..b40308c --- /dev/null +++ b/src/components/ui/form-time-input.tsx @@ -0,0 +1,173 @@ +import * as React from "react" +import { Clock } from "lucide-react" +import { Button } from "./button" +import { Input } from "./input" +import { Popover, PopoverContent, PopoverTrigger } from "./popover" +import { cn } from "../../lib/utils" + +interface TimeInputProps extends Omit, 'onChange' | 'value'> { + value: string + onChange: (value: string) => void + error?: boolean + required?: boolean + errorMessage?: string +} + +const FormTimeInput = React.forwardRef( + ({ value, onChange, error = false, required = false, errorMessage = 'Time is required', className, ...props }, ref) => { + const [displayValue, setDisplayValue] = React.useState(value) + const [isPickerOpen, setIsPickerOpen] = React.useState(false) + const [showError, setShowError] = React.useState(false) + const [touched, setTouched] = React.useState(false) + const containerRef = React.useRef(null) + const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number) + + // Check if value is invalid (check validity regardless of touch state) + const isInvalid = required && (!value || value.trim() === '') + const hasError = error || isInvalid + + React.useEffect(() => { + setDisplayValue(value) + }, [value]) + + const handleBlur = (e: React.FocusEvent) => { + const inputValue = e.target.value.trim() + setTouched(true) + setShowError(false) + + if (inputValue === '') { + // Update parent with empty value so validation works + onChange('') + return + } + + let input = inputValue.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: React.ChangeEvent) => { + const newValue = e.target.value + setDisplayValue(newValue) + // Propagate changes to parent immediately (including empty values) + if (newValue.trim() === '') { + onChange('') + } + } + + const handleFocus = () => { + setShowError(hasError) + } + + const handlePickerChange = (part: 'h' | 'm', val: number) => { + 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 ( +
+ + + + + + +
+
+
Hour
+
+ {Array.from({ length: 24 }, (_, i) => ( + + ))} +
+
+
+
Min
+
+ {Array.from({ length: 12 }, (_, i) => i * 5).map(minute => ( + + ))} +
+
+
+
+
+ {hasError && showError && ( +
+ {errorMessage} +
+ )} +
+ ) + } +) + +FormTimeInput.displayName = "FormTimeInput" + +export { FormTimeInput } diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..5431746 --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "../../lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..c23cb40 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "../../lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..37f3c45 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,33 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "../../lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverAnchor = PopoverPrimitive.Anchor + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..e921e73 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,157 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "../../lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..49910a8 --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "../../lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx new file mode 100644 index 0000000..a48b2c2 --- /dev/null +++ b/src/components/ui/slider.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import * as SliderPrimitive from "@radix-ui/react-slider" + +import { cn } from "../../lib/utils" + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..5125dc8 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "../../lib/utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..cbbe089 --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,30 @@ +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "../../lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/src/constants/defaults.js b/src/constants/defaults.js deleted file mode 100644 index 2a2da77..0000000 --- a/src/constants/defaults.js +++ /dev/null @@ -1,29 +0,0 @@ -// Application constants -export const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v5'; -export const LDX_TO_DAMPH_CONVERSION_FACTOR = 0.2948; - -// Default application state -export const getDefaultState = () => ({ - pkParams: { - damph: { halfLife: '11' }, - ldx: { halfLife: '0.8', absorptionRate: '1.5' }, - }, - doses: [ - { time: '06:30', dose: '25', label: 'morning' }, - { time: '12:30', dose: '10', label: 'midday' }, - { time: '17:00', dose: '10', label: 'afternoon' }, - { time: '22:00', dose: '10', label: 'evening' }, - { time: '01:00', dose: '0', label: 'night' }, - ], - steadyStateConfig: { daysOnMedication: '7' }, - therapeuticRange: { min: '10.5', max: '11.5' }, - doseIncrement: '2.5', - uiSettings: { - showDayTimeOnXAxis: true, - chartView: 'damph', - yAxisMin: '0', - yAxisMax: '16', - simulationDays: '3', - displayedDays: '2', - } -}); diff --git a/src/constants/defaults.ts b/src/constants/defaults.ts new file mode 100644 index 0000000..df3d2d9 --- /dev/null +++ b/src/constants/defaults.ts @@ -0,0 +1,79 @@ +// Application constants +export const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v5'; +export const LDX_TO_DAMPH_CONVERSION_FACTOR = 0.2948; + +// Type definitions +export interface PkParams { + damph: { halfLife: string }; + ldx: { halfLife: string; absorptionRate: string }; +} + +export interface Dose { + time: string; + dose: string; + label: string; +} + +export interface Deviation extends Dose { + dayOffset?: number; + isAdditional: boolean; +} + +export interface SteadyStateConfig { + daysOnMedication: string; +} + +export interface TherapeuticRange { + min: string; + max: string; +} + +export interface UiSettings { + showDayTimeOnXAxis: boolean; + chartView: 'damph' | 'ldx' | 'both'; + yAxisMin: string; + yAxisMax: string; + simulationDays: string; + displayedDays: string; +} + +export interface AppState { + pkParams: PkParams; + doses: Dose[]; + steadyStateConfig: SteadyStateConfig; + therapeuticRange: TherapeuticRange; + doseIncrement: string; + uiSettings: UiSettings; +} + +export interface ConcentrationPoint { + timeHours: number; + ldx: number; + damph: number; +} + +// Default application state +export const getDefaultState = (): AppState => ({ + pkParams: { + damph: { halfLife: '11' }, + ldx: { halfLife: '0.8', absorptionRate: '1.5' }, + }, + doses: [ + { time: '06:30', dose: '25', label: 'morning' }, + { time: '12:30', dose: '10', label: 'midday' }, + { time: '17:00', dose: '10', label: 'afternoon' }, + { time: '22:00', dose: '10', label: 'evening' }, + { time: '01:00', dose: '0', label: 'night' }, + ], + steadyStateConfig: { daysOnMedication: '7' }, + therapeuticRange: { min: '10.5', max: '11.5' }, + doseIncrement: '2.5', + uiSettings: { + showDayTimeOnXAxis: true, + chartView: 'both', + yAxisMin: '0', + yAxisMax: '16', + simulationDays: '3', + displayedDays: '2', + } +}); diff --git a/src/hooks/useAppState.js b/src/hooks/useAppState.ts similarity index 77% rename from src/hooks/useAppState.js rename to src/hooks/useAppState.ts index 27a0feb..9001af7 100644 --- a/src/hooks/useAppState.js +++ b/src/hooks/useAppState.ts @@ -1,8 +1,8 @@ import React from 'react'; -import { LOCAL_STORAGE_KEY, getDefaultState } from '../constants/defaults.js'; +import { LOCAL_STORAGE_KEY, getDefaultState, type AppState } from '../constants/defaults'; export const useAppState = () => { - const [appState, setAppState] = React.useState(getDefaultState); + const [appState, setAppState] = React.useState(getDefaultState); const [isLoaded, setIsLoaded] = React.useState(false); React.useEffect(() => { @@ -42,21 +42,28 @@ export const useAppState = () => { } }, [appState, isLoaded]); - const updateState = (key, value) => { + const updateState = (key: K, value: AppState[K]) => { setAppState(prev => ({ ...prev, [key]: value })); }; - const updateNestedState = (parentKey, childKey, value) => { + const updateNestedState =

( + parentKey: P, + childKey: string, + value: any + ) => { setAppState(prev => ({ ...prev, - [parentKey]: { ...prev[parentKey], [childKey]: value } + [parentKey]: { ...(prev[parentKey] as any), [childKey]: value } })); }; - const updateUiSetting = (key, value) => { + const updateUiSetting = ( + key: K, + value: AppState['uiSettings'][K] + ) => { const newUiSettings = { ...appState.uiSettings, [key]: value }; if (key === 'simulationDays') { - const simDaysNum = parseInt(value, 10) || 1; + const simDaysNum = parseInt(value as string, 10) || 1; const dispDaysNum = parseInt(newUiSettings.displayedDays, 10) || 1; if (dispDaysNum > simDaysNum) { newUiSettings.displayedDays = String(simDaysNum); diff --git a/src/hooks/useLanguage.js b/src/hooks/useLanguage.ts similarity index 71% rename from src/hooks/useLanguage.js rename to src/hooks/useLanguage.ts index ce1a796..4f432c5 100644 --- a/src/hooks/useLanguage.js +++ b/src/hooks/useLanguage.ts @@ -1,15 +1,15 @@ import React from 'react'; -import { translations, getInitialLanguage } from '../locales/index.js'; +import { translations, getInitialLanguage } from '../locales/index'; export const useLanguage = () => { const [currentLanguage, setCurrentLanguage] = React.useState(getInitialLanguage); // Get current translations - const t = translations[currentLanguage] || translations.en; + const t = translations[currentLanguage as keyof typeof translations] || translations.en; // Change language and save to localStorage - const changeLanguage = (lang) => { - if (translations[lang]) { + const changeLanguage = (lang: string) => { + if (translations[lang as keyof typeof translations]) { setCurrentLanguage(lang); localStorage.setItem('medPlanAssistant_language', lang); } diff --git a/src/hooks/useSimulation.js b/src/hooks/useSimulation.ts similarity index 54% rename from src/hooks/useSimulation.js rename to src/hooks/useSimulation.ts index 6a9fcf1..6f99355 100644 --- a/src/hooks/useSimulation.js +++ b/src/hooks/useSimulation.ts @@ -1,17 +1,27 @@ import React from 'react'; -import { calculateCombinedProfile } from '../utils/calculations.js'; -import { generateSuggestion } from '../utils/suggestions.js'; -import { timeToMinutes } from '../utils/timeUtils.js'; +import { calculateCombinedProfile } from '../utils/calculations'; +import { generateSuggestion } from '../utils/suggestions'; +import { timeToMinutes } from '../utils/timeUtils'; +import type { AppState, Deviation } from '../constants/defaults'; -export const useSimulation = (appState) => { +interface SuggestionResult { + text?: string; + time?: string; + dose?: string; + isAdditional?: boolean; + originalDose?: string; + dayOffset?: number; +} + +export const useSimulation = (appState: AppState) => { const { pkParams, doses, steadyStateConfig, doseIncrement, uiSettings } = appState; const { simulationDays } = uiSettings; - const [deviations, setDeviations] = React.useState([]); - const [suggestion, setSuggestion] = React.useState(null); + const [deviations, setDeviations] = React.useState([]); + const [suggestion, setSuggestion] = React.useState(null); const calculateCombinedProfileMemo = React.useCallback( - (doseSchedule, deviationList = [], correction = null) => + (doseSchedule = doses, deviationList: Deviation[] = [], correction: Deviation | null = null) => calculateCombinedProfile( doseSchedule, deviationList, @@ -20,7 +30,7 @@ export const useSimulation = (appState) => { simulationDays, pkParams ), - [steadyStateConfig, simulationDays, pkParams] + [doses, steadyStateConfig, simulationDays, pkParams] ); const generateSuggestionMemo = React.useCallback(() => { @@ -50,39 +60,56 @@ export const useSimulation = (appState) => { ); const correctedProfile = React.useMemo(() => - suggestion && suggestion.dose ? calculateCombinedProfileMemo(doses, deviations, suggestion) : null, + suggestion && suggestion.dose ? calculateCombinedProfileMemo(doses, deviations, suggestion as Deviation) : 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' }; + const templateDose = { time: '07:00', dose: '10', label: '' }; + const sortedDoses = [...doses].sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time)); + let nextDose: any = sortedDoses[0] || templateDose; + let nextDayOffset = 0; + 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 }; + nextDose = nextPlanned; + nextDayOffset = lastDev.dayOffset || 0; } else { - nextDose = { ...sortedDoses[0], dayOffset: (lastDev.dayOffset || 0) + 1 }; + nextDose = sortedDoses[0]; + nextDayOffset = (lastDev.dayOffset || 0) + 1; } } - setDeviations([...deviations, { ...nextDose, isAdditional: false, dayOffset: nextDose.dayOffset || 0 }]); + + // Use templateDose if nextDose has no time + if (!nextDose.time || nextDose.time === '') { + nextDose = templateDose; + } + + setDeviations([...deviations, { + time: nextDose.time, + dose: nextDose.dose, + label: nextDose.label || '', + isAdditional: false, + dayOffset: nextDayOffset + }]); }; - const removeDeviation = (index) => { + const removeDeviation = (index: number) => { setDeviations(deviations.filter((_, i) => i !== index)); }; - const handleDeviationChange = (index, field, value) => { + const handleDeviationChange = (index: number, field: keyof Deviation, value: any) => { const newDeviations = [...deviations]; - newDeviations[index][field] = value; + (newDeviations[index] as any)[field] = value; setDeviations(newDeviations); }; const applySuggestion = () => { if (!suggestion || !suggestion.dose) return; - setDeviations([...deviations, suggestion]); + setDeviations([...deviations, suggestion as Deviation]); setSuggestion(null); }; diff --git a/src/index.css b/src/index.css deleted file mode 100644 index b5c61c9..0000000 --- a/src/index.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/src/index.js b/src/index.js deleted file mode 100644 index d563c0f..0000000 --- a/src/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import './index.css'; -import App from './App'; -import reportWebVitals from './reportWebVitals'; - -const root = ReactDOM.createRoot(document.getElementById('root')); -root.render( - - - -); - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..1536d28 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './styles/global.css'; +import App from './App'; + +const rootElement = document.getElementById('root'); +if (!rootElement) throw new Error('Failed to find the root element'); + +const root = ReactDOM.createRoot(rootElement); +root.render( + + + +); diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..5557787 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,7 @@ +//import * as React from "react" +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/src/locales/de.js b/src/locales/de.ts similarity index 100% rename from src/locales/de.js rename to src/locales/de.ts diff --git a/src/locales/en.js b/src/locales/en.ts similarity index 100% rename from src/locales/en.js rename to src/locales/en.ts diff --git a/src/locales/index.js b/src/locales/index.ts similarity index 62% rename from src/locales/index.js rename to src/locales/index.ts index f34006e..934d1e9 100644 --- a/src/locales/index.js +++ b/src/locales/index.ts @@ -1,5 +1,5 @@ -import en from './en.js'; -import de from './de.js'; +import en from './en'; +import de from './de'; export const translations = { en, @@ -8,14 +8,14 @@ export const translations = { // Get browser language preference export const getBrowserLanguage = () => { - const browserLang = navigator.language || navigator.userLanguage; + const browserLang = navigator.language; return browserLang.startsWith('de') ? 'de' : 'en'; }; -// Get stored language or fall back to browser preference or English +// Get initial language from localStorage or browser preference export const getInitialLanguage = () => { const stored = localStorage.getItem('medPlanAssistant_language'); - if (stored && translations[stored]) { + if (stored && translations[stored as keyof typeof translations]) { return stored; } return getBrowserLanguage(); diff --git a/src/reportWebVitals.js b/src/reportWebVitals.js deleted file mode 100644 index 5253d3a..0000000 --- a/src/reportWebVitals.js +++ /dev/null @@ -1,13 +0,0 @@ -const reportWebVitals = onPerfEntry => { - if (onPerfEntry && onPerfEntry instanceof Function) { - import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { - getCLS(onPerfEntry); - getFID(onPerfEntry); - getFCP(onPerfEntry); - getLCP(onPerfEntry); - getTTFB(onPerfEntry); - }); - } -}; - -export default reportWebVitals; diff --git a/src/setupTests.js b/src/setupTests.ts similarity index 100% rename from src/setupTests.js rename to src/setupTests.ts diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..377386f --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,43 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 98%; + --foreground: 0 0% 10%; + --card: 0 0% 100%; + --card-foreground: 0 0% 10%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 10%; + --primary: 0 0% 15%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 94%; + --secondary-foreground: 0 0% 15%; + --muted: 220 10% 95%; + --muted-foreground: 0 0% 45%; + --accent: 220 10% 95%; + --accent-foreground: 0 0% 15%; + --destructive: 0 84% 60%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 88%; + --input: 0 0% 88%; + --ring: 0 0% 70%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.625rem; + } + + * { + border-color: hsl(var(--border)); + } + + body { + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + font-feature-settings: "rlig" 1, "calt" 1; + } +} diff --git a/src/utils/calculations.js b/src/utils/calculations.ts similarity index 60% rename from src/utils/calculations.js rename to src/utils/calculations.ts index ed9a491..e290fb4 100644 --- a/src/utils/calculations.js +++ b/src/utils/calculations.ts @@ -1,15 +1,21 @@ -import { timeToMinutes } from './timeUtils.js'; -import { calculateSingleDoseConcentration } from './pharmacokinetics.js'; +import { timeToMinutes } from './timeUtils'; +import { calculateSingleDoseConcentration } from './pharmacokinetics'; +import type { Dose, Deviation, SteadyStateConfig, PkParams, ConcentrationPoint } from '../constants/defaults'; + +interface DoseWithTime extends Omit { + time: number; + isPlan?: boolean; +} export const calculateCombinedProfile = ( - doseSchedule, - deviationList = [], - correction = null, - steadyStateConfig, - simulationDays, - pkParams -) => { - const dataPoints = []; + doseSchedule: Dose[], + deviationList: Deviation[] = [], + correction: Deviation | null = null, + steadyStateConfig: SteadyStateConfig, + simulationDays: string, + pkParams: PkParams +): ConcentrationPoint[] => { + const dataPoints: ConcentrationPoint[] = []; const timeStepHours = 0.25; const totalHours = (parseInt(simulationDays, 10) || 3) * 24; const daysToSimulate = Math.min(parseInt(steadyStateConfig.daysOnMedication, 10) || 0, 5); @@ -17,13 +23,21 @@ export const calculateCombinedProfile = ( for (let t = 0; t <= totalHours; t += timeStepHours) { let totalLdx = 0; let totalDamph = 0; - let allDoses = []; + const allDoses: DoseWithTime[] = []; const maxDayOffset = (parseInt(simulationDays, 10) || 3) - 1; for (let day = -daysToSimulate; day <= maxDayOffset; day++) { const dayOffset = day * 24 * 60; doseSchedule.forEach(d => { + // Skip doses with empty or invalid time values + const timeStr = String(d.time || '').trim(); + const doseStr = String(d.dose || '').trim(); + const doseNum = parseFloat(doseStr); + + if (!timeStr || timeStr === '' || !doseStr || doseStr === '' || doseNum === 0 || isNaN(doseNum)) { + return; + } allDoses.push({ ...d, time: timeToMinutes(d.time) + dayOffset, isPlan: true }); }); } @@ -34,6 +48,14 @@ export const calculateCombinedProfile = ( } currentDeviations.forEach(dev => { + // Skip deviations with empty or invalid time values + const timeStr = String(dev.time || '').trim(); + const doseStr = String(dev.dose || '').trim(); + const doseNum = parseFloat(doseStr); + + if (!timeStr || timeStr === '' || !doseStr || doseStr === '' || doseNum === 0 || isNaN(doseNum)) { + return; + } const devTime = timeToMinutes(dev.time) + (dev.dayOffset || 0) * 24 * 60; if (!dev.isAdditional) { const closestDoseIndex = allDoses.reduce((closest, dose, index) => { diff --git a/src/utils/pharmacokinetics.js b/src/utils/pharmacokinetics.ts similarity index 81% rename from src/utils/pharmacokinetics.js rename to src/utils/pharmacokinetics.ts index ca4fdcc..61cbf4f 100644 --- a/src/utils/pharmacokinetics.js +++ b/src/utils/pharmacokinetics.ts @@ -1,7 +1,16 @@ -import { LDX_TO_DAMPH_CONVERSION_FACTOR } from '../constants/defaults.js'; +import { LDX_TO_DAMPH_CONVERSION_FACTOR, type PkParams } from '../constants/defaults'; + +interface ConcentrationResult { + ldx: number; + damph: number; +} // Pharmacokinetic calculations -export const calculateSingleDoseConcentration = (dose, timeSinceDoseHours, pkParams) => { +export const calculateSingleDoseConcentration = ( + dose: string, + timeSinceDoseHours: number, + pkParams: PkParams +): ConcentrationResult => { const numDose = parseFloat(dose) || 0; if (timeSinceDoseHours < 0 || numDose <= 0) return { ldx: 0, damph: 0 }; diff --git a/src/utils/suggestions.js b/src/utils/suggestions.ts similarity index 58% rename from src/utils/suggestions.js rename to src/utils/suggestions.ts index 9f6c2cb..d6932a4 100644 --- a/src/utils/suggestions.js +++ b/src/utils/suggestions.ts @@ -1,14 +1,24 @@ -import { timeToMinutes } from './timeUtils.js'; -import { calculateCombinedProfile } from './calculations.js'; +import { timeToMinutes } from './timeUtils'; +import { calculateCombinedProfile } from './calculations'; +import type { Dose, Deviation, SteadyStateConfig, PkParams } from '../constants/defaults'; + +interface SuggestionResult { + text?: string; + time?: string; + dose?: string; + isAdditional?: boolean; + originalDose?: string; + dayOffset?: number; +} export const generateSuggestion = ( - doses, - deviations, - doseIncrement, - simulationDays, - steadyStateConfig, - pkParams -) => { + doses: Dose[], + deviations: Deviation[], + doseIncrement: string, + simulationDays: string, + steadyStateConfig: SteadyStateConfig, + pkParams: PkParams +): SuggestionResult | null => { if (deviations.length === 0) { return null; } @@ -18,12 +28,23 @@ export const generateSuggestion = ( (timeToMinutes(b.time) + (b.dayOffset || 0) * 1440) ).pop(); + if (!lastDeviation) return null; + const deviationTimeTotalMinutes = timeToMinutes(lastDeviation.time) + (lastDeviation.dayOffset || 0) * 1440; - let nextDose = null; + type DoseWithOffset = Dose & { dayOffset: number }; + let nextDose: DoseWithOffset | null = null; let minDiff = Infinity; doses.forEach(d => { + // Skip doses with empty or invalid time/dose values + const timeStr = String(d.time || '').trim(); + const doseStr = String(d.dose || '').trim(); + const doseNum = parseFloat(doseStr); + + if (!timeStr || timeStr === '' || !doseStr || doseStr === '' || doseNum === 0 || isNaN(doseNum)) { + return; + } const doseTimeInMinutes = timeToMinutes(d.time); for (let i = 0; i < (parseInt(simulationDays, 10) || 1); i++) { const absoluteTime = doseTimeInMinutes + i * 1440; @@ -39,11 +60,14 @@ export const generateSuggestion = ( return { text: "Keine passende nächste Dosis für Korrektur gefunden." }; } + // Type assertion after null check + const confirmedNextDose: DoseWithOffset = nextDose; + 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 nextDoseTimeHours = (timeToMinutes(confirmedNextDose.time) + (confirmedNextDose.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; @@ -56,14 +80,14 @@ export const generateSuggestion = ( const doseAdjustmentFactor = 0.5; let doseChange = concentrationDifference / doseAdjustmentFactor; doseChange = Math.round(doseChange / numDoseIncrement) * numDoseIncrement; - let suggestedDoseValue = (parseFloat(nextDose.dose) || 0) + doseChange; + let suggestedDoseValue = (parseFloat(confirmedNextDose.dose) || 0) + doseChange; suggestedDoseValue = Math.max(0, Math.min(70, suggestedDoseValue)); return { - time: nextDose.time, + time: confirmedNextDose.time, dose: String(suggestedDoseValue), isAdditional: false, - originalDose: nextDose.dose, - dayOffset: nextDose.dayOffset + originalDose: confirmedNextDose.dose, + dayOffset: confirmedNextDose.dayOffset }; }; diff --git a/src/utils/timeUtils.js b/src/utils/timeUtils.ts similarity index 62% rename from src/utils/timeUtils.js rename to src/utils/timeUtils.ts index 62a0969..87a8ab8 100644 --- a/src/utils/timeUtils.js +++ b/src/utils/timeUtils.ts @@ -1,5 +1,5 @@ -// --- Helper Functions --- -export const timeToMinutes = (timeStr) => { +// Time utility functions +export const timeToMinutes = (timeStr: string): number => { if (!timeStr || !timeStr.includes(':')) return 0; const [hours, minutes] = timeStr.split(':').map(Number); return hours * 60 + minutes; diff --git a/tailwind.config.js b/tailwind.config.js index 1490793..29347ed 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,10 +1,59 @@ /** @type {import('tailwindcss').Config} */ module.exports = { + darkMode: ["class"], content: [ "./src/**/*.{js,jsx,ts,tsx}", ], theme: { - extend: {}, + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))' + } + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + } + } }, - plugins: [], + plugins: [require("tailwindcss-animate")], } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..10c9f43 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "node", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..e69de29