Update migrated js to ts and shadcn

This commit is contained in:
2025-11-26 20:00:39 +00:00
parent d5938046a2
commit 551ba9fd62
51 changed files with 1702 additions and 937 deletions

22
components.json Normal file
View File

@@ -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": {}
}

View File

@@ -3,16 +3,34 @@
"version": "0.1.1", "version": "0.1.1",
"private": true, "private": true,
"dependencies": { "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/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.8.0", "@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.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", "npx": "^10.2.2",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-hook-form": "^7.66.1",
"react-is": "^19.2.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"recharts": "^3.3.0", "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": { "scripts": {
"start": "cross-env HOST=0.0.0.0 BROWSER=none CHOKIDAR_USEPOLLING=true FAST_REFRESH=false react-scripts start", "start": "cross-env HOST=0.0.0.0 BROWSER=none CHOKIDAR_USEPOLLING=true FAST_REFRESH=false react-scripts start",
@@ -41,11 +59,15 @@
}, },
"devDependencies": { "devDependencies": {
"@hint/configuration-web-recommended": "^8.2.24", "@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", "cross-env": "^10.1.0",
"hint": "^7.1.13", "hint": "^7.1.13",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"puppeteer": "^24.27.0", "puppeteer": "^24.27.0",
"tailwindcss": "^3.4.18" "tailwindcss": "^3.4.18",
"typescript": "^4.9.5"
} }
} }

View File

@@ -1,8 +1,10 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import App from './App'; import App from './App';
// @ts-ignore
test('renders learn react link', () => { test('renders learn react link', () => {
render(<App />); render(<App />);
const linkElement = screen.getByText(/learn react/i); const linkElement = screen.getByText(/learn react/i);
// @ts-ignore
expect(linkElement).toBeInTheDocument(); expect(linkElement).toBeInTheDocument();
}); });

View File

@@ -1,17 +1,18 @@
import React from 'react'; import React from 'react';
// Components // Components
import DoseSchedule from './components/DoseSchedule.js'; import DoseSchedule from './components/dose-schedule';
import DeviationList from './components/DeviationList.js'; import DeviationList from './components/deviation-list';
import SuggestionPanel from './components/SuggestionPanel.js'; import SuggestionPanel from './components/suggestion-panel';
import SimulationChart from './components/SimulationChart.js'; import SimulationChart from './components/simulation-chart';
import Settings from './components/Settings.js'; import Settings from './components/settings';
import LanguageSelector from './components/LanguageSelector.js'; import LanguageSelector from './components/language-selector';
import { Button } from './components/ui/button';
// Custom Hooks // Custom Hooks
import { useAppState } from './hooks/useAppState.js'; import { useAppState } from './hooks/useAppState';
import { useSimulation } from './hooks/useSimulation.js'; import { useSimulation } from './hooks/useSimulation';
import { useLanguage } from './hooks/useLanguage.js'; import { useLanguage } from './hooks/useLanguage';
// --- Main Component --- // --- Main Component ---
const MedPlanAssistant = () => { const MedPlanAssistant = () => {
@@ -55,13 +56,13 @@ const MedPlanAssistant = () => {
} = useSimulation(appState); } = useSimulation(appState);
return ( return (
<div className="bg-gray-100 font-sans p-4 sm:p-6 lg:p-8"> <div className="min-h-screen bg-background p-4 sm:p-6 lg:p-8">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<header className="mb-8"> <header className="mb-8">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<h1 className="text-3xl md:text-4xl font-bold text-gray-800">{t.appTitle}</h1> <h1 className="text-3xl md:text-4xl font-bold tracking-tight">{t.appTitle}</h1>
<p className="text-gray-600 mt-1">{t.appSubtitle}</p> <p className="text-muted-foreground mt-1">{t.appSubtitle}</p>
</div> </div>
<LanguageSelector currentLanguage={currentLanguage} onLanguageChange={changeLanguage} t={t} /> <LanguageSelector currentLanguage={currentLanguage} onLanguageChange={changeLanguage} t={t} />
</div> </div>
@@ -70,26 +71,26 @@ const MedPlanAssistant = () => {
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Both Columns - Chart */} {/* Both Columns - Chart */}
<div className="xl:col-span-2 bg-white p-5 rounded-lg shadow-sm border min-h-[600px] flex flex-col"> <div className="xl:col-span-2 bg-card p-6 rounded-lg border min-h-[600px] flex flex-col">
<div className="flex justify-center space-x-2 mb-4"> <div className="flex justify-center gap-2 mb-4">
<button <Button
onClick={() => updateUiSetting('chartView', 'damph')} onClick={() => updateUiSetting('chartView', 'damph')}
className={`px-4 py-2 text-sm font-medium rounded-md ${chartView === 'damph' ? 'bg-sky-600 text-white' : 'bg-gray-200 text-gray-700'}`} variant={chartView === 'damph' ? 'default' : 'secondary'}
> >
{t.dAmphetamine} {t.dAmphetamine}
</button> </Button>
<button <Button
onClick={() => updateUiSetting('chartView', 'ldx')} onClick={() => updateUiSetting('chartView', 'ldx')}
className={`px-4 py-2 text-sm font-medium rounded-md ${chartView === 'ldx' ? 'bg-sky-600 text-white' : 'bg-gray-200 text-gray-700'}`} variant={chartView === 'ldx' ? 'default' : 'secondary'}
> >
{t.lisdexamfetamine} {t.lisdexamfetamine}
</button> </Button>
<button <Button
onClick={() => updateUiSetting('chartView', 'both')} onClick={() => updateUiSetting('chartView', 'both')}
className={`px-4 py-2 text-sm font-medium rounded-md ${chartView === 'both' ? 'bg-sky-600 text-white' : 'bg-gray-200 text-gray-700'}`} variant={chartView === 'both' ? 'default' : 'secondary'}
> >
{t.both} {t.both}
</button> </Button>
</div> </div>
<SimulationChart <SimulationChart
@@ -112,7 +113,7 @@ const MedPlanAssistant = () => {
<DoseSchedule <DoseSchedule
doses={doses} doses={doses}
doseIncrement={doseIncrement} doseIncrement={doseIncrement}
onUpdateDoses={(newDoses) => updateState('doses', newDoses)} onUpdateDoses={(newDoses: any) => updateState('doses', newDoses)}
t={t} t={t}
/> />
@@ -139,8 +140,8 @@ const MedPlanAssistant = () => {
pkParams={pkParams} pkParams={pkParams}
therapeuticRange={therapeuticRange} therapeuticRange={therapeuticRange}
uiSettings={uiSettings} uiSettings={uiSettings}
onUpdatePkParams={(key, value) => updateNestedState('pkParams', key, value)} onUpdatePkParams={(key: any, value: any) => updateNestedState('pkParams', key, value)}
onUpdateTherapeuticRange={(key, value) => updateNestedState('therapeuticRange', key, value)} onUpdateTherapeuticRange={(key: any, value: any) => updateNestedState('therapeuticRange', key, value)}
onUpdateUiSetting={updateUiSetting} onUpdateUiSetting={updateUiSetting}
onReset={handleReset} onReset={handleReset}
t={t} t={t}
@@ -149,8 +150,8 @@ const MedPlanAssistant = () => {
</div> </div>
<footer className="mt-8 p-4 bg-gray-100 rounded-lg text-sm text-gray-700 border"> <footer className="mt-8 p-4 bg-muted rounded-lg text-sm text-muted-foreground border">
<h3 className="font-semibold mb-2">{t.importantNote}</h3> <h3 className="font-semibold mb-2 text-foreground">{t.importantNote}</h3>
<p>{t.disclaimer}</p> <p>{t.disclaimer}</p>
</footer> </footer>
</div> </div>

View File

@@ -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 (
<div className="bg-amber-50 p-5 rounded-lg shadow-sm border border-amber-200">
<h2 className="text-xl font-semibold mb-4 text-gray-700">{t.deviationsFromPlan}</h2>
{deviations.map((dev, index) => (
<div key={index} className="flex items-center gap-3 mb-2 p-2 bg-white rounded flex-wrap">
<select
value={dev.dayOffset || 0}
onChange={e => onDeviationChange(index, 'dayOffset', parseInt(e.target.value, 10))}
className="p-2 border rounded-md text-sm"
>
{[...Array(parseInt(simulationDays, 10) || 1).keys()].map(day => (
<option key={day} value={day}>{t.day} {day + 1}</option>
))}
</select>
<TimeInput
value={dev.time}
onChange={newTime => onDeviationChange(index, 'time', newTime)}
errorMessage={t.errorTimeRequired}
/>
<div className="w-32">
<NumericInput
value={dev.dose}
onChange={newDose => onDeviationChange(index, 'dose', newDose)}
increment={doseIncrement}
min={0}
unit={t.mg}
errorMessage={t.fieldRequired}
/>
</div>
<button
onClick={() => onRemoveDeviation(index)}
className="text-red-500 hover:text-red-700 font-bold text-lg px-1"
>
&times;
</button>
<div className="flex items-center" title={t.additionalTooltip}>
<input
type="checkbox"
id={`add_dose_${index}`}
checked={dev.isAdditional}
onChange={e => onDeviationChange(index, 'isAdditional', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500"
/>
<label htmlFor={`add_dose_${index}`} className="ml-2 text-xs text-gray-600 whitespace-nowrap">
{t.additional}
</label>
</div>
</div>
))}
<button
onClick={onAddDeviation}
className="mt-2 w-full bg-amber-500 text-white py-2 rounded-md hover:bg-amber-600 text-sm"
>
{t.addDeviation}
</button>
</div>
);
};export default DeviationList;

View File

@@ -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 (
<div className="bg-white p-5 rounded-lg shadow-sm border">
<h2 className="text-xl font-semibold mb-4 text-gray-700">{t.myPlan}</h2>
{doses.map((dose, index) => (
<div key={index} className="flex items-center space-x-3 mb-3">
<TimeInput
value={dose.time}
onChange={newTime => onUpdateDoses(doses.map((d, i) => i === index ? {...d, time: newTime} : d))}
errorMessage={t.errorTimeRequired}
/>
<div className="w-40">
<NumericInput
value={dose.dose}
onChange={newDose => onUpdateDoses(doses.map((d, i) => i === index ? {...d, dose: newDose} : d))}
increment={doseIncrement}
min={0}
unit={t.mg}
errorMessage={t.fieldRequired}
/>
</div>
<span className="text-gray-600 text-sm flex-1">{t[dose.label] || dose.label}</span>
</div>
))}
</div>
);
};
export default DoseSchedule;

View File

@@ -1,19 +0,0 @@
import React from 'react';
const LanguageSelector = ({ currentLanguage, onLanguageChange, t }) => {
return (
<div className="flex items-center space-x-2">
<label className="text-sm font-medium text-gray-600">{t.language}:</label>
<select
value={currentLanguage}
onChange={(e) => onLanguageChange(e.target.value)}
className="p-1 border rounded text-sm bg-white"
>
<option value="en">{t.english}</option>
<option value="de">{t.german}</option>
</select>
</div>
);
};
export default LanguageSelector;

View File

@@ -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 (
<div
ref={containerRef}
className="relative inline-block"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="relative inline-flex items-center">
<div className={`relative inline-flex items-center ${hasError ? 'ring-2 ring-red-500 rounded-md pointer-events-auto' : ''}`}>
<button
onClick={() => updateValue(-1)}
className="px-2 py-1 border rounded-l-md bg-gray-100 bg-gray-100 hover:bg-gray-200 text-gray-600 hover:text-gray-800 text-lg font-bold"
tabIndex={-1}
>
-
</button>
<input
type="text"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleInputBlurWrapper}
onFocus={handleFocus}
placeholder={placeholder}
style={{ width: '4em', minWidth: '4em', maxWidth: '8em' }}
className={`p-2 border-t border-b text-sm ${getAlignmentClass()} ${hasError ? 'bg-transparent' : ''}`}
/>
<button
onClick={() => updateValue(1)}
className={`px-2 py-1 border-t border-b ${showClearButton ? 'border-l' : 'border-l border-r rounded-r-md'} bg-gray-100 bg-gray-100 hover:bg-gray-200 text-gray-600 hover:text-gray-800 text-lg font-bold`}
tabIndex={-1}
>
+
</button>
{showClearButton && (
<button
onClick={handleClear}
className="px-2 py-1 border rounded-r-md bg-gray-100 bg-gray-100 hover:bg-gray-200 text-gray-600 hover:text-gray-800 text-gray-600 hover:text-gray-800"
tabIndex={-1}
title={clearButtonTitle || "Clear"}
>
{clearButtonText || (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
)}
</button>)}
</div>
{unit && <span className="ml-2 text-gray-500 text-sm whitespace-nowrap">{unit}</span>}
</div>
{hasError && showErrorTooltip && (
<div
className={`absolute z-50 bg-white ring-2 ring-red-500 rounded-md shadow-lg p-2 flex items-start gap-2 ${
tooltipPosition.top ? 'bottom-full mb-1' : 'top-full mt-1'
} ${
tooltipPosition.left ? 'right-0' : 'left-0'
}`}
style={{ minWidth: '200px', maxWidth: '400px' }}
>
<div className="flex-shrink-0 mt-0.5">
<svg className="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#fa5252"/>
<path d="M17 15.59l-1.41 1.41-3.59-3.59-3.59 3.59-1.41-1.41 3.59-3.59-3.59-3.59 1.41-1.41 3.59 3.59 3.59-3.59 1.41 1.41-3.59 3.59z" fill="white"/>
</svg>
</div>
<span className="text-xs text-red-700 whitespace-nowrap">{errorMessage}</span>
</div>
)}
</div>
);
};
export default NumericInput;

View File

@@ -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 = (<svg fill="#000000" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid" width="24px" height="24px" viewBox="0 0 31.812 31.906"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M23.263,9.969 L27.823,9.969 C28.372,9.969 28.817,10.415 28.817,10.966 C28.817,11.516 28.456,11.906 27.906,11.906 L21.154,11.906 C21.058,11.935 20.962,11.963 20.863,11.963 C20.608,11.963 20.354,11.865 20.160,11.671 C19.916,11.426 19.843,11.088 19.906,10.772 L19.906,3.906 C19.906,3.355 20.313,2.989 20.863,2.989 C21.412,2.989 21.857,3.435 21.857,3.986 L21.857,8.559 L30.103,0.289 C30.491,-0.100 31.120,-0.100 31.509,0.289 C31.897,0.679 31.897,1.310 31.509,1.699 L23.263,9.969 ZM11.914,27.917 C11.914,28.468 11.469,28.914 10.920,28.914 C10.370,28.914 9.926,28.468 9.926,27.917 L9.926,23.344 L1.680,31.613 C1.486,31.808 1.231,31.906 0.977,31.906 C0.723,31.906 0.468,31.808 0.274,31.613 C-0.114,31.224 -0.114,30.593 0.274,30.203 L8.520,21.934 L3.960,21.934 C3.410,21.934 2.966,21.488 2.966,20.937 C2.966,20.386 3.410,19.940 3.960,19.940 L10.920,19.940 C10.920,19.940 10.921,19.940 10.921,19.940 C11.050,19.940 11.179,19.967 11.300,20.017 C11.543,20.118 11.737,20.312 11.838,20.556 C11.888,20.678 11.914,20.807 11.914,20.937 L11.914,27.917 ZM10.920,11.963 C10.821,11.963 10.724,11.935 10.629,11.906 L3.906,11.906 C3.356,11.906 2.966,11.516 2.966,10.966 C2.966,10.415 3.410,9.969 3.960,9.969 L8.520,9.969 L0.274,1.699 C-0.114,1.310 -0.114,0.679 0.274,0.289 C0.662,-0.100 1.292,-0.100 1.680,0.289 L9.926,8.559 L9.926,3.986 C9.926,3.435 10.370,2.989 10.920,2.989 C11.469,2.989 11.914,3.435 11.914,3.986 L11.914,10.965 C11.914,11.221 11.817,11.476 11.623,11.671 C11.429,11.865 11.174,11.963 10.920,11.963 ZM20.174,20.222 C20.345,20.047 20.585,19.940 20.863,19.940 L27.823,19.940 C28.372,19.940 28.817,20.386 28.817,20.937 C28.817,21.488 28.372,21.934 27.823,21.934 L23.263,21.934 L31.509,30.203 C31.897,30.593 31.897,31.224 31.509,31.613 C31.314,31.808 31.060,31.906 30.806,31.906 C30.551,31.906 30.297,31.808 30.103,31.613 L21.857,23.344 L21.857,27.917 C21.857,28.468 21.412,28.914 20.863,28.914 C20.313,28.914 19.906,28.457 19.906,27.906 L19.906,21.130 C19.843,20.815 19.916,20.477 20.160,20.232 C20.164,20.228 20.170,20.227 20.174,20.222 Z"></path></g></svg>);
return (
<div className="bg-white p-5 rounded-lg shadow-sm border">
<h2 className="text-xl font-semibold mb-4 text-gray-700">{t.advancedSettings}</h2>
<div className="space-y-4 text-sm">
<div className="flex items-center">
<input
type="checkbox"
id="showDayTimeOnXAxis"
checked={showDayTimeOnXAxis}
onChange={e => onUpdateUiSetting('showDayTimeOnXAxis', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500"
/>
<label htmlFor="showDayTimeOnXAxis" className="ml-3 block font-medium text-gray-600">
{t.show24hTimeAxis}
</label>
</div>
<div>
<label className="block font-medium text-gray-600 pt-2">{t.simulationDuration}</label>
<NumericInput
value={simulationDays}
onChange={val => onUpdateUiSetting('simulationDays', val)}
increment={'1'}
min={2}
max={7}
placeholder="#"
unit={t.days}
errorMessage={t.errorNumberRequired}
/>
</div>
<div>
<label className="block font-medium text-gray-600">{t.displayedDays}</label>
<NumericInput
value={displayedDays}
onChange={val => onUpdateUiSetting('displayedDays', val)}
increment={'1'}
min={1}
max={parseInt(simulationDays, 10) || 1}
placeholder="#"
unit={t.days}
errorMessage={t.errorNumberRequired}
/>
</div>
<div>
<label className="block font-medium text-gray-600 pt-2">{t.yAxisRange}</label>
<div className="flex items-center mt-1">
<div className="w-30">
<NumericInput
value={yAxisMin}
onChange={val => onUpdateUiSetting('yAxisMin', val)}
increment={'5'}
min={0}
placeholder={t.auto}
allowEmpty={true}
clearButton={true}
clearButtonText={clearButtonSVG || t.yAxisRangeAutoButton}
clearButtonTitle={t.yAxisRangeAutoButtonTitle}
/>
</div>
<span className="text-gray-500 px-2">-</span>
<div className="w-32">
<NumericInput
value={yAxisMax}
onChange={val => onUpdateUiSetting('yAxisMax', val)}
increment={'5'}
min={0}
placeholder={t.auto}
unit="ng/ml"
allowEmpty={true}
clearButton={true}
clearButtonText={clearButtonSVG || t.yAxisRangeAutoButton}
clearButtonTitle={t.yAxisRangeAutoButtonTitle}
/>
</div>
</div>
</div>
<div>
<label className="block font-medium text-gray-600">{t.therapeuticRange}</label>
<div className="flex items-center mt-1">
<div className="w-30">
<NumericInput
value={therapeuticRange.min}
onChange={val => onUpdateTherapeuticRange('min', val)}
increment={'0.5'}
min={0}
placeholder={t.min}
errorMessage={t.errorNumberRequired}
/>
</div>
<span className="text-gray-500 px-2">-</span>
<div className="w-32">
<NumericInput
value={therapeuticRange.max}
onChange={val => onUpdateTherapeuticRange('max', val)}
increment={'0.5'}
min={0}
placeholder={t.max}
unit="ng/ml"
errorMessage={t.errorNumberRequired}
/>
</div>
</div>
</div>
<h3 className="text-lg font-semibold mt-4 pt-4 border-t">{t.dAmphetamineParameters}</h3>
<div>
<label className="block font-medium text-gray-600">{t.halfLife}</label>
<NumericInput
value={pkParams.damph.halfLife}
onChange={val => onUpdatePkParams('damph', { halfLife: val })}
increment={'0.5'}
min={0.1}
placeholder="#.#"
unit="h"
errorMessage={t.errorNumberRequired}
/>
</div>
<h3 className="text-lg font-semibold mt-4 pt-4 border-t">{t.lisdexamfetamineParameters}</h3>
<div>
<label className="block font-medium text-gray-600">{t.conversionHalfLife}</label>
<NumericInput
value={pkParams.ldx.halfLife}
onChange={val => onUpdatePkParams('ldx', { halfLife: val })}
increment={'0.1'}
min={0.1}
placeholder="#.#"
unit="h"
errorMessage={t.errorNumberRequired}
/>
</div>
<div>
<label className="block font-medium text-gray-600">{t.absorptionRate}</label>
<NumericInput
value={pkParams.ldx.absorptionRate}
onChange={val => onUpdatePkParams('ldx', { absorptionRate: val })}
increment={'0.1'}
min={0.1}
placeholder="#.#"
unit={t.faster}
errorMessage={t.errorNumberRequired}
/>
</div>
<div className="pt-4">
<button
onClick={onReset}
className="w-full bg-red-600 text-white py-2 rounded-md hover:bg-red-700 text-sm"
>
{t.resetAllSettings}
</button>
</div>
</div>
</div>
);
};
export default Settings;

View File

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

View File

@@ -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 (
<div
ref={containerRef}
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="flex items-center">
<div className={`${hasError ? 'ring-2 ring-red-500 rounded-md pointer-events-auto' : ''}`}>
<input
type="text"
value={displayValue}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
placeholder="HH:MM"
className={`p-2 border rounded-md w-24 text-sm text-center ${hasError ? 'border-transparent' : ''}`}
/>
</div>
<button
onClick={() => setIsPickerOpen(!isPickerOpen)}
className="ml-2 p-2 text-gray-500 hover:text-gray-700"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.414-1.415L11 9.586V6z" clipRule="evenodd" />
</svg>
</button>
{isPickerOpen && (
<div ref={pickerRef} className="absolute top-full mt-2 z-[60] bg-white p-4 rounded-lg shadow-xl border w-64">
<div className="text-center text-lg font-bold mb-3">{value}</div>
<div>
<div className="mb-2"><span className="font-semibold">Hour:</span></div>
<div className="grid grid-cols-6 gap-1">
{[...Array(24).keys()].map(h => (
<button
key={h}
onClick={() => handlePickerChange('h', h)}
className={`p-1 rounded text-xs ${h === pickerHours ? 'bg-sky-500 text-white' : 'bg-gray-200 hover:bg-sky-200'}`}
>
{String(h).padStart(2,'0')}
</button>
))}
</div>
</div>
<div className="mt-4">
<div className="mb-2"><span className="font-semibold">Minute:</span></div>
<div className="grid grid-cols-4 gap-1">
{[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55].map(m => (
<button
key={m}
onClick={() => handlePickerChange('m', m)}
className={`p-1 rounded text-xs ${m === pickerMinutes ? 'bg-sky-500 text-white' : 'bg-gray-200 hover:bg-sky-200'}`}
>
{String(m).padStart(2,'0')}
</button>
))}
</div>
</div>
<button
onClick={() => setIsPickerOpen(false)}
className="mt-4 w-full bg-gray-600 text-white py-1 rounded-md text-sm"
>
Close
</button>
</div>
)}
{hasError && showErrorTooltip && (
<div
className={`absolute z-50 bg-white ring-2 ring-red-500 rounded-md shadow-lg p-2 flex items-start gap-2 ${
tooltipPosition.top ? 'bottom-full mb-1' : 'top-full mt-1'
} ${
tooltipPosition.left ? 'right-0' : 'left-0'
}`}
style={{ minWidth: '200px', maxWidth: '400px' }}
>
<div className="flex-shrink-0 mt-0.5">
<svg className="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" fill="#fa5252"/>
<path d="M17 15.59l-1.41 1.41-3.59-3.59-3.59 3.59-1.41-1.41 3.59-3.59-3.59-3.59 1.41-1.41 3.59 3.59 3.59-3.59 1.41 1.41-3.59 3.59z" fill="white"/>
</svg>
</div>
<span className="text-xs text-red-700 whitespace-nowrap">{errorMessage}</span>
</div>
)}
</div>
</div>
);
};
export default TimeInput;

View File

@@ -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 (
<Card className="bg-amber-50/50 border-amber-200">
<CardHeader>
<CardTitle>{t.deviationsFromPlan}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{deviations.map((dev: any, index: number) => (
<div key={index} className="flex items-center gap-3 p-3 bg-card rounded-lg border flex-wrap">
<Select
value={String(dev.dayOffset || 0)}
onValueChange={val => onDeviationChange(index, 'dayOffset', parseInt(val, 10))}
>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[...Array(parseInt(simulationDays, 10) || 1).keys()].map(day => (
<SelectItem key={day} value={String(day)}>
{t.day} {day + 1}
</SelectItem>
))}
</SelectContent>
</Select>
<FormTimeInput
value={dev.time}
onChange={newTime => onDeviationChange(index, 'time', newTime)}
required={true}
errorMessage={t.timeRequired || 'Time is required'}
/>
<FormNumericInput
value={dev.dose}
onChange={newDose => onDeviationChange(index, 'dose', newDose)}
increment={doseIncrement}
min={0}
unit={t.mg}
required={true}
errorMessage={t.doseRequired || 'Dose is required'}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemoveDeviation(index)}
className="text-destructive hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<Switch
id={`add_dose_${index}`}
checked={dev.isAdditional}
onCheckedChange={checked => onDeviationChange(index, 'isAdditional', checked)}
/>
<Label htmlFor={`add_dose_${index}`} className="text-xs whitespace-nowrap">
{t.additional}
</Label>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t.additionalTooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
))}
<Button
type="button"
onClick={onAddDeviation}
className="w-full bg-amber-500 hover:bg-amber-600 text-white"
>
{t.addDeviation}
</Button>
</CardContent>
</Card>
);
};
export default DeviationList;

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>{t.myPlan}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{doses.map((dose: any, index: number) => (
<div key={index} className="flex items-center gap-3">
<FormTimeInput
value={dose.time}
onChange={newTime => onUpdateDoses(doses.map((d: any, i: number) => i === index ? {...d, time: newTime} : d))}
required={true}
errorMessage={t.timeRequired || 'Time is required'}
/>
<FormNumericInput
value={dose.dose}
onChange={newDose => 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'}
/>
<span className="text-sm text-muted-foreground flex-1">{t[dose.label] || dose.label}</span>
</div>
))}
</CardContent>
</Card>
);
};
export default DoseSchedule;

View File

@@ -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 (
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">{t.language}:</Label>
<Select value={currentLanguage} onValueChange={onLanguageChange}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">{t.english}</SelectItem>
<SelectItem value="de">{t.german}</SelectItem>
</SelectContent>
</Select>
</div>
);
};
export default LanguageSelector;

178
src/components/settings.tsx Normal file
View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>{t.advancedSettings}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="showDayTimeOnXAxis" className="font-medium">
{t.show24hTimeAxis}
</Label>
<Switch
id="showDayTimeOnXAxis"
checked={showDayTimeOnXAxis}
onCheckedChange={checked => onUpdateUiSetting('showDayTimeOnXAxis', checked)}
/>
</div>
<div className="space-y-2">
<Label className="font-medium">{t.simulationDuration}</Label>
<FormNumericInput
value={simulationDays}
onChange={val => onUpdateUiSetting('simulationDays', val)}
increment={1}
min={2}
max={7}
unit={t.days}
required={true}
errorMessage={t.simulationDaysRequired || 'Simulation days is required'}
/>
</div>
<div className="space-y-2">
<Label className="font-medium">{t.displayedDays}</Label>
<FormNumericInput
value={displayedDays}
onChange={val => onUpdateUiSetting('displayedDays', val)}
increment={1}
min={1}
max={parseInt(simulationDays, 10) || 1}
unit={t.days}
required={true}
errorMessage={t.displayedDaysRequired || 'Displayed days is required'}
/>
</div>
<div className="space-y-2">
<Label className="font-medium">{t.yAxisRange}</Label>
<div className="flex items-center gap-2">
<FormNumericInput
value={yAxisMin}
onChange={val => onUpdateUiSetting('yAxisMin', val)}
increment={5}
min={0}
placeholder={t.auto}
allowEmpty={true}
clearButton={true}
/>
<span className="text-muted-foreground">-</span>
<FormNumericInput
value={yAxisMax}
onChange={val => onUpdateUiSetting('yAxisMax', val)}
increment={5}
min={0}
placeholder={t.auto}
unit="ng/ml"
allowEmpty={true}
clearButton={true}
/>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium">{t.therapeuticRange}</Label>
<div className="flex items-center gap-2">
<FormNumericInput
value={therapeuticRange.min}
onChange={val => onUpdateTherapeuticRange('min', val)}
increment={0.5}
min={0}
placeholder={t.min}
required={true}
errorMessage={t.therapeuticRangeMinRequired || 'Minimum therapeutic range is required'}
/>
<span className="text-muted-foreground">-</span>
<FormNumericInput
value={therapeuticRange.max}
onChange={val => onUpdateTherapeuticRange('max', val)}
increment={0.5}
min={0}
placeholder={t.max}
unit="ng/ml"
required={true}
errorMessage={t.therapeuticRangeMaxRequired || 'Maximum therapeutic range is required'}
/>
</div>
</div>
<Separator className="my-4" />
<h3 className="text-lg font-semibold">{t.dAmphetamineParameters}</h3>
<div className="space-y-2">
<Label className="font-medium">{t.halfLife}</Label>
<FormNumericInput
value={pkParams.damph.halfLife}
onChange={val => onUpdatePkParams('damph', { halfLife: val })}
increment={0.5}
min={0.1}
unit="h"
required={true}
errorMessage={t.halfLifeRequired || 'Half-life is required'}
/>
</div>
<Separator className="my-4" />
<h3 className="text-lg font-semibold">{t.lisdexamfetamineParameters}</h3>
<div className="space-y-2">
<Label className="font-medium">{t.conversionHalfLife}</Label>
<FormNumericInput
value={pkParams.ldx.halfLife}
onChange={val => onUpdatePkParams('ldx', { halfLife: val })}
increment={0.1}
min={0.1}
unit="h"
required={true}
errorMessage={t.conversionHalfLifeRequired || 'Conversion half-life is required'}
/>
</div>
<div className="space-y-2">
<Label className="font-medium">{t.absorptionRate}</Label>
<FormNumericInput
value={pkParams.ldx.absorptionRate}
onChange={val => onUpdatePkParams('ldx', { absorptionRate: val })}
increment={0.1}
min={0.1}
unit={t.faster}
required={true}
errorMessage={t.absorptionRateRequired || 'Absorption rate is required'}
/>
</div>
<Separator className="my-4" />
<Button
type="button"
onClick={onReset}
variant="destructive"
className="w-full"
>
{t.resetAllSettings}
</Button>
</CardContent>
</Card>
);
};
export default Settings;

View File

@@ -13,12 +13,11 @@ const SimulationChart = ({
yAxisMin, yAxisMin,
yAxisMax, yAxisMax,
t t
}) => { }: any) => {
const totalHours = (parseInt(simulationDays, 10) || 3) * 24; 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) // Generate ticks for continuous time axis (every 6 hours)
const dayTimeTicks = React.useMemo(() => { const chartTicks = React.useMemo(() => {
const ticks = []; const ticks = [];
for (let i = 0; i <= totalHours; i += 6) { for (let i = 0; i <= totalHours; i += 6) {
ticks.push(i); ticks.push(i);
@@ -46,17 +45,25 @@ const SimulationChart = ({
dataKey="timeHours" dataKey="timeHours"
type="number" type="number"
domain={[0, totalHours]} domain={[0, totalHours]}
ticks={showDayTimeOnXAxis ? dayTimeTicks : chartTicks} ticks={chartTicks}
tickFormatter={(h) => `${showDayTimeOnXAxis ? h % 24 : h}${t.hour}`} 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" xAxisId="hours"
/> />
<YAxis <YAxis
label={{ value: t.concentration, angle: -90, position: 'insideLeft', offset: -10 }} label={{ value: t.concentration, angle: -90, position: 'insideLeft', offset: -10 }}
domain={chartDomain} domain={chartDomain as any}
allowDecimals={false} allowDecimals={false}
/> />
<Tooltip <Tooltip
formatter={(value, name) => [`${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}`} labelFormatter={(label) => `${t.hour.replace('h', 'Hour')}: ${label}${t.hour}`}
/> />
<Legend verticalAlign="top" height={36} /> <Legend verticalAlign="top" height={36} />

View File

@@ -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 (
<Card className="bg-sky-50/50 border-l-4 border-sky-500">
<CardHeader>
<CardTitle className="text-lg">{t.whatIf}</CardTitle>
</CardHeader>
<CardContent>
{suggestion.dose ? (
<div className="space-y-3">
<p className="text-sm text-sky-800">
{t.suggestion}: <span className="font-bold">{suggestion.dose}{t.mg}</span> ({t.instead} {suggestion.originalDose}{t.mg}) {t.at} <span className="font-bold">{suggestion.time}</span>.
</p>
<Button
onClick={onApplySuggestion}
className="w-full bg-sky-600 hover:bg-sky-700"
>
{t.applySuggestion}
</Button>
</div>
) : (
<p className="text-sm text-sky-800">{suggestion.text}</p>
)}
</CardContent>
</Card>
);
};
export default SuggestionPanel;

View File

@@ -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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -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<React.InputHTMLAttributes<HTMLInputElement>, '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<HTMLInputElement, NumericInputProps>(
({
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<HTMLDivElement>(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<HTMLInputElement>) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault()
updateValue(e.key === 'ArrowUp' ? 1 : -1)
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value
if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
onChange(val)
}
}
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
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 (
<div ref={containerRef} className={cn("relative flex items-center gap-2", className)}>
<div className="flex items-center flex-1">
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"h-9 w-9 rounded-r-none",
hasError && "border-destructive"
)}
onClick={() => updateValue(-1)}
tabIndex={-1}
>
<Minus className="h-4 w-4" />
</Button>
<Input
ref={ref}
type="text"
value={value}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
className={cn(
"rounded-none border-x-0 h-9",
getAlignmentClass(),
hasError && "border-destructive focus-visible:ring-destructive"
)}
{...props}
/>
{clearButton && allowEmpty ? (
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"h-9 w-9 rounded-l-none",
hasError && "border-destructive"
)}
onClick={() => onChange('')}
tabIndex={-1}
>
<X className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"h-9 w-9 rounded-l-none",
hasError && "border-destructive"
)}
onClick={() => updateValue(1)}
tabIndex={-1}
>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
{hasError && showError && (
<div className="absolute top-full left-0 mt-1 z-50 w-64 bg-destructive text-destructive-foreground text-xs p-2 rounded-md shadow-lg">
{errorMessage}
</div>
)}
</div>
)
}
)
FormNumericInput.displayName = "FormNumericInput"
export { FormNumericInput }

View File

@@ -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<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
value: string
onChange: (value: string) => void
error?: boolean
required?: boolean
errorMessage?: string
}
const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
({ 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<HTMLDivElement>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div ref={containerRef} className={cn("relative flex items-center gap-2", className)}>
<Input
ref={ref}
type="text"
value={displayValue}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
placeholder="HH:MM"
className={cn(
"w-24",
hasError && "border-destructive focus-visible:ring-destructive"
)}
{...props}
/>
<Popover open={isPickerOpen} onOpenChange={setIsPickerOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className={cn(hasError && "border-destructive")}
>
<Clock className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-3 bg-popover shadow-md border">
<div className="flex gap-2">
<div className="flex flex-col gap-1">
<div className="text-xs font-medium text-center mb-1">Hour</div>
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto">
{Array.from({ length: 24 }, (_, i) => (
<Button
key={i}
type="button"
variant={pickerHours === i ? "default" : "outline"}
size="sm"
className="h-8 w-10"
onClick={() => {
handlePickerChange('h', i)
setIsPickerOpen(false)
}}
>
{String(i).padStart(2, '0')}
</Button>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<div className="text-xs font-medium text-center mb-1">Min</div>
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto">
{Array.from({ length: 12 }, (_, i) => i * 5).map(minute => (
<Button
key={minute}
type="button"
variant={pickerMinutes === minute ? "default" : "outline"}
size="sm"
className="h-8 w-10"
onClick={() => {
handlePickerChange('m', minute)
setIsPickerOpen(false)
}}
>
{String(minute).padStart(2, '0')}
</Button>
))}
</div>
</div>
</div>
</PopoverContent>
</Popover>
{hasError && showError && (
<div className="absolute top-full left-0 mt-1 z-50 w-48 bg-destructive text-destructive-foreground text-xs p-2 rounded-md shadow-lg">
{errorMessage}
</div>
)}
</div>
)
}
)
FormTimeInput.displayName = "FormTimeInput"
export { FormTimeInput }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -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<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -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<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -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<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -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<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -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<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -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<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -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<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

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

79
src/constants/defaults.ts Normal file
View File

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

View File

@@ -1,8 +1,8 @@
import React from 'react'; 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 = () => { export const useAppState = () => {
const [appState, setAppState] = React.useState(getDefaultState); const [appState, setAppState] = React.useState<AppState>(getDefaultState);
const [isLoaded, setIsLoaded] = React.useState(false); const [isLoaded, setIsLoaded] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
@@ -42,21 +42,28 @@ export const useAppState = () => {
} }
}, [appState, isLoaded]); }, [appState, isLoaded]);
const updateState = (key, value) => { const updateState = <K extends keyof AppState>(key: K, value: AppState[K]) => {
setAppState(prev => ({ ...prev, [key]: value })); setAppState(prev => ({ ...prev, [key]: value }));
}; };
const updateNestedState = (parentKey, childKey, value) => { const updateNestedState = <P extends keyof AppState>(
parentKey: P,
childKey: string,
value: any
) => {
setAppState(prev => ({ setAppState(prev => ({
...prev, ...prev,
[parentKey]: { ...prev[parentKey], [childKey]: value } [parentKey]: { ...(prev[parentKey] as any), [childKey]: value }
})); }));
}; };
const updateUiSetting = (key, value) => { const updateUiSetting = <K extends keyof AppState['uiSettings']>(
key: K,
value: AppState['uiSettings'][K]
) => {
const newUiSettings = { ...appState.uiSettings, [key]: value }; const newUiSettings = { ...appState.uiSettings, [key]: value };
if (key === 'simulationDays') { if (key === 'simulationDays') {
const simDaysNum = parseInt(value, 10) || 1; const simDaysNum = parseInt(value as string, 10) || 1;
const dispDaysNum = parseInt(newUiSettings.displayedDays, 10) || 1; const dispDaysNum = parseInt(newUiSettings.displayedDays, 10) || 1;
if (dispDaysNum > simDaysNum) { if (dispDaysNum > simDaysNum) {
newUiSettings.displayedDays = String(simDaysNum); newUiSettings.displayedDays = String(simDaysNum);

View File

@@ -1,15 +1,15 @@
import React from 'react'; import React from 'react';
import { translations, getInitialLanguage } from '../locales/index.js'; import { translations, getInitialLanguage } from '../locales/index';
export const useLanguage = () => { export const useLanguage = () => {
const [currentLanguage, setCurrentLanguage] = React.useState(getInitialLanguage); const [currentLanguage, setCurrentLanguage] = React.useState(getInitialLanguage);
// Get current translations // 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 // Change language and save to localStorage
const changeLanguage = (lang) => { const changeLanguage = (lang: string) => {
if (translations[lang]) { if (translations[lang as keyof typeof translations]) {
setCurrentLanguage(lang); setCurrentLanguage(lang);
localStorage.setItem('medPlanAssistant_language', lang); localStorage.setItem('medPlanAssistant_language', lang);
} }

View File

@@ -1,17 +1,27 @@
import React from 'react'; import React from 'react';
import { calculateCombinedProfile } from '../utils/calculations.js'; import { calculateCombinedProfile } from '../utils/calculations';
import { generateSuggestion } from '../utils/suggestions.js'; import { generateSuggestion } from '../utils/suggestions';
import { timeToMinutes } from '../utils/timeUtils.js'; 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 { pkParams, doses, steadyStateConfig, doseIncrement, uiSettings } = appState;
const { simulationDays } = uiSettings; const { simulationDays } = uiSettings;
const [deviations, setDeviations] = React.useState([]); const [deviations, setDeviations] = React.useState<Deviation[]>([]);
const [suggestion, setSuggestion] = React.useState(null); const [suggestion, setSuggestion] = React.useState<SuggestionResult | null>(null);
const calculateCombinedProfileMemo = React.useCallback( const calculateCombinedProfileMemo = React.useCallback(
(doseSchedule, deviationList = [], correction = null) => (doseSchedule = doses, deviationList: Deviation[] = [], correction: Deviation | null = null) =>
calculateCombinedProfile( calculateCombinedProfile(
doseSchedule, doseSchedule,
deviationList, deviationList,
@@ -20,7 +30,7 @@ export const useSimulation = (appState) => {
simulationDays, simulationDays,
pkParams pkParams
), ),
[steadyStateConfig, simulationDays, pkParams] [doses, steadyStateConfig, simulationDays, pkParams]
); );
const generateSuggestionMemo = React.useCallback(() => { const generateSuggestionMemo = React.useCallback(() => {
@@ -50,39 +60,56 @@ export const useSimulation = (appState) => {
); );
const correctedProfile = React.useMemo(() => 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] [doses, deviations, suggestion, calculateCombinedProfileMemo]
); );
const addDeviation = () => { const addDeviation = () => {
const templateDose = { time: '07:00', dose: '10', label: '' };
const sortedDoses = [...doses].sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time)); const sortedDoses = [...doses].sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time));
let nextDose = sortedDoses[0] || { time: '08:00', dose: '25' }; let nextDose: any = sortedDoses[0] || templateDose;
let nextDayOffset = 0;
if (deviations.length > 0) { if (deviations.length > 0) {
const lastDev = deviations[deviations.length - 1]; const lastDev = deviations[deviations.length - 1];
const lastDevTime = timeToMinutes(lastDev.time) + (lastDev.dayOffset || 0) * 24 * 60; const lastDevTime = timeToMinutes(lastDev.time) + (lastDev.dayOffset || 0) * 24 * 60;
const nextPlanned = sortedDoses.find(d => timeToMinutes(d.time) > (lastDevTime % (24*60))); const nextPlanned = sortedDoses.find(d => timeToMinutes(d.time) > (lastDevTime % (24*60)));
if (nextPlanned) { if (nextPlanned) {
nextDose = { ...nextPlanned, dayOffset: lastDev.dayOffset }; nextDose = nextPlanned;
nextDayOffset = lastDev.dayOffset || 0;
} else { } 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)); setDeviations(deviations.filter((_, i) => i !== index));
}; };
const handleDeviationChange = (index, field, value) => { const handleDeviationChange = (index: number, field: keyof Deviation, value: any) => {
const newDeviations = [...deviations]; const newDeviations = [...deviations];
newDeviations[index][field] = value; (newDeviations[index] as any)[field] = value;
setDeviations(newDeviations); setDeviations(newDeviations);
}; };
const applySuggestion = () => { const applySuggestion = () => {
if (!suggestion || !suggestion.dose) return; if (!suggestion || !suggestion.dose) return;
setDeviations([...deviations, suggestion]); setDeviations([...deviations, suggestion as Deviation]);
setSuggestion(null); setSuggestion(null);
}; };

View File

@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);
// 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();

14
src/index.tsx Normal file
View File

@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);

7
src/lib/utils.ts Normal file
View File

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

View File

@@ -1,5 +1,5 @@
import en from './en.js'; import en from './en';
import de from './de.js'; import de from './de';
export const translations = { export const translations = {
en, en,
@@ -8,14 +8,14 @@ export const translations = {
// Get browser language preference // Get browser language preference
export const getBrowserLanguage = () => { export const getBrowserLanguage = () => {
const browserLang = navigator.language || navigator.userLanguage; const browserLang = navigator.language;
return browserLang.startsWith('de') ? 'de' : 'en'; 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 = () => { export const getInitialLanguage = () => {
const stored = localStorage.getItem('medPlanAssistant_language'); const stored = localStorage.getItem('medPlanAssistant_language');
if (stored && translations[stored]) { if (stored && translations[stored as keyof typeof translations]) {
return stored; return stored;
} }
return getBrowserLanguage(); return getBrowserLanguage();

View File

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

43
src/styles/global.css Normal file
View File

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

View File

@@ -1,15 +1,21 @@
import { timeToMinutes } from './timeUtils.js'; import { timeToMinutes } from './timeUtils';
import { calculateSingleDoseConcentration } from './pharmacokinetics.js'; import { calculateSingleDoseConcentration } from './pharmacokinetics';
import type { Dose, Deviation, SteadyStateConfig, PkParams, ConcentrationPoint } from '../constants/defaults';
interface DoseWithTime extends Omit<Dose, 'time'> {
time: number;
isPlan?: boolean;
}
export const calculateCombinedProfile = ( export const calculateCombinedProfile = (
doseSchedule, doseSchedule: Dose[],
deviationList = [], deviationList: Deviation[] = [],
correction = null, correction: Deviation | null = null,
steadyStateConfig, steadyStateConfig: SteadyStateConfig,
simulationDays, simulationDays: string,
pkParams pkParams: PkParams
) => { ): ConcentrationPoint[] => {
const dataPoints = []; const dataPoints: ConcentrationPoint[] = [];
const timeStepHours = 0.25; const timeStepHours = 0.25;
const totalHours = (parseInt(simulationDays, 10) || 3) * 24; const totalHours = (parseInt(simulationDays, 10) || 3) * 24;
const daysToSimulate = Math.min(parseInt(steadyStateConfig.daysOnMedication, 10) || 0, 5); 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) { for (let t = 0; t <= totalHours; t += timeStepHours) {
let totalLdx = 0; let totalLdx = 0;
let totalDamph = 0; let totalDamph = 0;
let allDoses = []; const allDoses: DoseWithTime[] = [];
const maxDayOffset = (parseInt(simulationDays, 10) || 3) - 1; const maxDayOffset = (parseInt(simulationDays, 10) || 3) - 1;
for (let day = -daysToSimulate; day <= maxDayOffset; day++) { for (let day = -daysToSimulate; day <= maxDayOffset; day++) {
const dayOffset = day * 24 * 60; const dayOffset = day * 24 * 60;
doseSchedule.forEach(d => { 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 }); allDoses.push({ ...d, time: timeToMinutes(d.time) + dayOffset, isPlan: true });
}); });
} }
@@ -34,6 +48,14 @@ export const calculateCombinedProfile = (
} }
currentDeviations.forEach(dev => { 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; const devTime = timeToMinutes(dev.time) + (dev.dayOffset || 0) * 24 * 60;
if (!dev.isAdditional) { if (!dev.isAdditional) {
const closestDoseIndex = allDoses.reduce((closest, dose, index) => { const closestDoseIndex = allDoses.reduce((closest, dose, index) => {

View File

@@ -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 // Pharmacokinetic calculations
export const calculateSingleDoseConcentration = (dose, timeSinceDoseHours, pkParams) => { export const calculateSingleDoseConcentration = (
dose: string,
timeSinceDoseHours: number,
pkParams: PkParams
): ConcentrationResult => {
const numDose = parseFloat(dose) || 0; const numDose = parseFloat(dose) || 0;
if (timeSinceDoseHours < 0 || numDose <= 0) return { ldx: 0, damph: 0 }; if (timeSinceDoseHours < 0 || numDose <= 0) return { ldx: 0, damph: 0 };

View File

@@ -1,14 +1,24 @@
import { timeToMinutes } from './timeUtils.js'; import { timeToMinutes } from './timeUtils';
import { calculateCombinedProfile } from './calculations.js'; 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 = ( export const generateSuggestion = (
doses, doses: Dose[],
deviations, deviations: Deviation[],
doseIncrement, doseIncrement: string,
simulationDays, simulationDays: string,
steadyStateConfig, steadyStateConfig: SteadyStateConfig,
pkParams pkParams: PkParams
) => { ): SuggestionResult | null => {
if (deviations.length === 0) { if (deviations.length === 0) {
return null; return null;
} }
@@ -18,12 +28,23 @@ export const generateSuggestion = (
(timeToMinutes(b.time) + (b.dayOffset || 0) * 1440) (timeToMinutes(b.time) + (b.dayOffset || 0) * 1440)
).pop(); ).pop();
if (!lastDeviation) return null;
const deviationTimeTotalMinutes = timeToMinutes(lastDeviation.time) + (lastDeviation.dayOffset || 0) * 1440; 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; let minDiff = Infinity;
doses.forEach(d => { 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); const doseTimeInMinutes = timeToMinutes(d.time);
for (let i = 0; i < (parseInt(simulationDays, 10) || 1); i++) { for (let i = 0; i < (parseInt(simulationDays, 10) || 1); i++) {
const absoluteTime = doseTimeInMinutes + i * 1440; const absoluteTime = doseTimeInMinutes + i * 1440;
@@ -39,11 +60,14 @@ export const generateSuggestion = (
return { text: "Keine passende nächste Dosis für Korrektur gefunden." }; 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 numDoseIncrement = parseFloat(doseIncrement) || 1;
const idealProfile = calculateCombinedProfile(doses, [], null, steadyStateConfig, simulationDays, pkParams); const idealProfile = calculateCombinedProfile(doses, [], null, steadyStateConfig, simulationDays, pkParams);
const deviatedProfile = calculateCombinedProfile(doses, deviations, 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 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 deviatedConcentration = deviatedProfile.find(p => Math.abs(p.timeHours - nextDoseTimeHours) < 0.1)?.damph || 0;
@@ -56,14 +80,14 @@ export const generateSuggestion = (
const doseAdjustmentFactor = 0.5; const doseAdjustmentFactor = 0.5;
let doseChange = concentrationDifference / doseAdjustmentFactor; let doseChange = concentrationDifference / doseAdjustmentFactor;
doseChange = Math.round(doseChange / numDoseIncrement) * numDoseIncrement; 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)); suggestedDoseValue = Math.max(0, Math.min(70, suggestedDoseValue));
return { return {
time: nextDose.time, time: confirmedNextDose.time,
dose: String(suggestedDoseValue), dose: String(suggestedDoseValue),
isAdditional: false, isAdditional: false,
originalDose: nextDose.dose, originalDose: confirmedNextDose.dose,
dayOffset: nextDose.dayOffset dayOffset: confirmedNextDose.dayOffset
}; };
}; };

View File

@@ -1,5 +1,5 @@
// --- Helper Functions --- // Time utility functions
export const timeToMinutes = (timeStr) => { export const timeToMinutes = (timeStr: string): number => {
if (!timeStr || !timeStr.includes(':')) return 0; if (!timeStr || !timeStr.includes(':')) return 0;
const [hours, minutes] = timeStr.split(':').map(Number); const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes; return hours * 60 + minutes;

View File

@@ -1,10 +1,59 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
darkMode: ["class"],
content: [ content: [
"./src/**/*.{js,jsx,ts,tsx}", "./src/**/*.{js,jsx,ts,tsx}",
], ],
theme: { 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))'
}, },
plugins: [], 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: [require("tailwindcss-animate")],
} }

25
tsconfig.json Normal file
View File

@@ -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"]
}

0
vite.config.ts Normal file
View File