Update new unified dose management, style/other improvements
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -35,3 +35,7 @@ yarn-error.log*
|
|||||||
/.env
|
/.env
|
||||||
/public/static/
|
/public/static/
|
||||||
/hint-report/
|
/hint-report/
|
||||||
|
|
||||||
|
~*
|
||||||
|
\#*
|
||||||
|
_*
|
||||||
|
|||||||
59
src/App.tsx
59
src/App.tsx
@@ -12,9 +12,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import DoseSchedule from './components/dose-schedule';
|
import DaySchedule from './components/day-schedule';
|
||||||
import DeviationList from './components/deviation-list';
|
|
||||||
import SuggestionPanel from './components/suggestion-panel';
|
|
||||||
import SimulationChart from './components/simulation-chart';
|
import SimulationChart from './components/simulation-chart';
|
||||||
import Settings from './components/settings';
|
import Settings from './components/settings';
|
||||||
import LanguageSelector from './components/language-selector';
|
import LanguageSelector from './components/language-selector';
|
||||||
@@ -31,15 +29,19 @@ const MedPlanAssistant = () => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
appState,
|
appState,
|
||||||
updateState,
|
|
||||||
updateNestedState,
|
updateNestedState,
|
||||||
updateUiSetting,
|
updateUiSetting,
|
||||||
handleReset
|
handleReset,
|
||||||
|
addDay,
|
||||||
|
removeDay,
|
||||||
|
addDoseToDay,
|
||||||
|
removeDoseFromDay,
|
||||||
|
updateDoseInDay
|
||||||
} = useAppState();
|
} = useAppState();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
pkParams,
|
pkParams,
|
||||||
doses,
|
days,
|
||||||
therapeuticRange,
|
therapeuticRange,
|
||||||
doseIncrement,
|
doseIncrement,
|
||||||
uiSettings
|
uiSettings
|
||||||
@@ -50,21 +52,15 @@ const MedPlanAssistant = () => {
|
|||||||
chartView,
|
chartView,
|
||||||
yAxisMin,
|
yAxisMin,
|
||||||
yAxisMax,
|
yAxisMax,
|
||||||
|
showTemplateDay,
|
||||||
simulationDays,
|
simulationDays,
|
||||||
displayedDays
|
displayedDays
|
||||||
} = uiSettings;
|
} = uiSettings;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
deviations,
|
combinedProfile,
|
||||||
suggestion,
|
templateProfile
|
||||||
idealProfile,
|
} = useSimulation(appState);
|
||||||
deviatedProfile,
|
|
||||||
correctedProfile,
|
|
||||||
addDeviation,
|
|
||||||
removeDeviation,
|
|
||||||
handleDeviationChange,
|
|
||||||
applySuggestion
|
|
||||||
} = useSimulation(appState, t);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background p-4 sm:p-6 lg:p-8">
|
<div className="min-h-screen bg-background p-4 sm:p-6 lg:p-8">
|
||||||
@@ -105,9 +101,8 @@ const MedPlanAssistant = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SimulationChart
|
<SimulationChart
|
||||||
idealProfile={idealProfile}
|
combinedProfile={combinedProfile}
|
||||||
deviatedProfile={deviatedProfile}
|
templateProfile={showTemplateDay ? templateProfile : null}
|
||||||
correctedProfile={correctedProfile}
|
|
||||||
chartView={chartView}
|
chartView={chartView}
|
||||||
showDayTimeOnXAxis={showDayTimeOnXAxis}
|
showDayTimeOnXAxis={showDayTimeOnXAxis}
|
||||||
therapeuticRange={therapeuticRange}
|
therapeuticRange={therapeuticRange}
|
||||||
@@ -121,26 +116,14 @@ const MedPlanAssistant = () => {
|
|||||||
|
|
||||||
{/* Left Column - Controls */}
|
{/* Left Column - Controls */}
|
||||||
<div className="xl:col-span-1 space-y-6">
|
<div className="xl:col-span-1 space-y-6">
|
||||||
<DoseSchedule
|
<DaySchedule
|
||||||
doses={doses}
|
days={days}
|
||||||
doseIncrement={doseIncrement}
|
doseIncrement={doseIncrement}
|
||||||
onUpdateDoses={(newDoses: any) => updateState('doses', newDoses)}
|
onAddDay={addDay}
|
||||||
t={t}
|
onRemoveDay={removeDay}
|
||||||
/>
|
onAddDose={addDoseToDay}
|
||||||
|
onRemoveDose={removeDoseFromDay}
|
||||||
<DeviationList
|
onUpdateDose={updateDoseInDay}
|
||||||
deviations={deviations}
|
|
||||||
doseIncrement={doseIncrement}
|
|
||||||
simulationDays={simulationDays}
|
|
||||||
onAddDeviation={addDeviation}
|
|
||||||
onRemoveDeviation={removeDeviation}
|
|
||||||
onDeviationChange={handleDeviationChange}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SuggestionPanel
|
|
||||||
suggestion={suggestion}
|
|
||||||
onApplySuggestion={applySuggestion}
|
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
154
src/components/day-schedule.tsx
Normal file
154
src/components/day-schedule.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Day Schedule Component
|
||||||
|
*
|
||||||
|
* Manages day-based medication schedules with doses.
|
||||||
|
* Allows adding/removing days, cloning days, and managing doses within each day.
|
||||||
|
*
|
||||||
|
* @author Andreas Weyer
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import { FormTimeInput } from './ui/form-time-input';
|
||||||
|
import { FormNumericInput } from './ui/form-numeric-input';
|
||||||
|
import { Plus, Copy, Trash2 } from 'lucide-react';
|
||||||
|
import type { DayGroup } from '../constants/defaults';
|
||||||
|
|
||||||
|
interface DayScheduleProps {
|
||||||
|
days: DayGroup[];
|
||||||
|
doseIncrement: string;
|
||||||
|
onAddDay: (cloneFromDayId?: string) => void;
|
||||||
|
onRemoveDay: (dayId: string) => void;
|
||||||
|
onAddDose: (dayId: string) => void;
|
||||||
|
onRemoveDose: (dayId: string, doseId: string) => void;
|
||||||
|
onUpdateDose: (dayId: string, doseId: string, field: 'time' | 'ldx' | 'damph', value: string) => void;
|
||||||
|
t: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DaySchedule: React.FC<DayScheduleProps> = ({
|
||||||
|
days,
|
||||||
|
doseIncrement,
|
||||||
|
onAddDay,
|
||||||
|
onRemoveDay,
|
||||||
|
onAddDose,
|
||||||
|
onRemoveDose,
|
||||||
|
onUpdateDose,
|
||||||
|
t
|
||||||
|
}) => {
|
||||||
|
const canAddDay = days.length < 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{days.map((day, dayIndex) => (
|
||||||
|
<Card key={day.id}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{day.isTemplate ? t.regularPlan : t.dayNumber.replace('{{number}}', String(dayIndex + 1))}
|
||||||
|
</CardTitle>
|
||||||
|
{day.isTemplate && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{t.day} 1
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{canAddDay && (
|
||||||
|
<Button
|
||||||
|
onClick={() => onAddDay(day.id)}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
title={t.cloneDay}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!day.isTemplate && (
|
||||||
|
<Button
|
||||||
|
onClick={() => onRemoveDay(day.id)}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||||
|
title={t.removeDay}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{/* Dose table header */}
|
||||||
|
<div className="grid grid-cols-[120px_1fr_auto] gap-3 text-sm font-medium text-muted-foreground">
|
||||||
|
<div>{t.time}</div>
|
||||||
|
<div>{t.ldx} (mg)</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dose rows */}
|
||||||
|
{day.doses.map((dose) => (
|
||||||
|
<div key={dose.id} className="grid grid-cols-[120px_1fr_auto] gap-3 items-center">
|
||||||
|
<FormTimeInput
|
||||||
|
value={dose.time}
|
||||||
|
onChange={(value) => onUpdateDose(day.id, dose.id, 'time', value)}
|
||||||
|
required={true}
|
||||||
|
errorMessage={t.errorTimeRequired}
|
||||||
|
/>
|
||||||
|
<FormNumericInput
|
||||||
|
value={dose.ldx}
|
||||||
|
onChange={(value) => onUpdateDose(day.id, dose.id, 'ldx', value)}
|
||||||
|
increment={doseIncrement}
|
||||||
|
min={0}
|
||||||
|
unit="mg"
|
||||||
|
required={true}
|
||||||
|
errorMessage={t.errorNumberRequired}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => onRemoveDose(day.id, dose.id)}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={day.isTemplate && day.doses.length === 1}
|
||||||
|
className="h-9 w-9 p-0"
|
||||||
|
title={t.removeDose}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add dose button */}
|
||||||
|
{day.doses.length < 5 && (
|
||||||
|
<Button
|
||||||
|
onClick={() => onAddDose(day.id)}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full mt-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{t.addDose}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add day button */}
|
||||||
|
{canAddDay && (
|
||||||
|
<Button
|
||||||
|
onClick={() => onAddDay()}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{t.addDay}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DaySchedule;
|
||||||
@@ -27,7 +27,7 @@ const Settings = ({
|
|||||||
onReset,
|
onReset,
|
||||||
t
|
t
|
||||||
}: any) => {
|
}: any) => {
|
||||||
const { showDayTimeOnXAxis, yAxisMin, yAxisMax, simulationDays, displayedDays } = uiSettings;
|
const { showDayTimeOnXAxis, yAxisMin, yAxisMax, showTemplateDay, simulationDays, displayedDays } = uiSettings;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -46,17 +46,28 @@ const Settings = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Label htmlFor="showTemplateDay" className="font-medium">
|
||||||
|
{t.showTemplateDayInChart}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="showTemplateDay"
|
||||||
|
checked={showTemplateDay}
|
||||||
|
onCheckedChange={checked => onUpdateUiSetting('showTemplateDay', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="font-medium">{t.simulationDuration}</Label>
|
<Label className="font-medium">{t.simulationDuration}</Label>
|
||||||
<FormNumericInput
|
<FormNumericInput
|
||||||
value={simulationDays}
|
value={simulationDays}
|
||||||
onChange={val => onUpdateUiSetting('simulationDays', val)}
|
onChange={val => onUpdateUiSetting('simulationDays', val)}
|
||||||
increment={1}
|
increment={1}
|
||||||
min={2}
|
min={3}
|
||||||
max={7}
|
max={7}
|
||||||
unit={t.days}
|
unit={t.days}
|
||||||
required={true}
|
required={true}
|
||||||
errorMessage={t.simulationDaysRequired || 'Simulation days is required'}
|
errorMessage={t.errorNumberRequired}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,10 +78,10 @@ const Settings = ({
|
|||||||
onChange={val => onUpdateUiSetting('displayedDays', val)}
|
onChange={val => onUpdateUiSetting('displayedDays', val)}
|
||||||
increment={1}
|
increment={1}
|
||||||
min={1}
|
min={1}
|
||||||
max={parseInt(simulationDays, 10) || 1}
|
max={parseInt(simulationDays, 10) || 3}
|
||||||
unit={t.days}
|
unit={t.days}
|
||||||
required={true}
|
required={true}
|
||||||
errorMessage={t.displayedDaysRequired || 'Displayed days is required'}
|
errorMessage={t.errorNumberRequired}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,8 @@ const CHART_COLORS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const SimulationChart = ({
|
const SimulationChart = ({
|
||||||
idealProfile,
|
combinedProfile,
|
||||||
deviatedProfile,
|
templateProfile,
|
||||||
correctedProfile,
|
|
||||||
chartView,
|
chartView,
|
||||||
showDayTimeOnXAxis,
|
showDayTimeOnXAxis,
|
||||||
therapeuticRange,
|
therapeuticRange,
|
||||||
@@ -57,8 +56,6 @@ const SimulationChart = ({
|
|||||||
return ticks;
|
return ticks;
|
||||||
}, [totalHours]);
|
}, [totalHours]);
|
||||||
|
|
||||||
const chartWidthPercentage = Math.max(100, (totalHours / ( (parseInt(displayedDays, 10) || 2) * 25)) * 100);
|
|
||||||
|
|
||||||
const chartDomain = React.useMemo(() => {
|
const chartDomain = React.useMemo(() => {
|
||||||
const numMin = parseFloat(yAxisMin);
|
const numMin = parseFloat(yAxisMin);
|
||||||
const numMax = parseFloat(yAxisMax);
|
const numMax = parseFloat(yAxisMax);
|
||||||
@@ -71,49 +68,134 @@ const SimulationChart = ({
|
|||||||
const mergedData = React.useMemo(() => {
|
const mergedData = React.useMemo(() => {
|
||||||
const dataMap = new Map();
|
const dataMap = new Map();
|
||||||
|
|
||||||
// Add ideal profile data
|
// Add combined profile data (actual plan with all days)
|
||||||
idealProfile?.forEach((point: any) => {
|
combinedProfile?.forEach((point: any) => {
|
||||||
dataMap.set(point.timeHours, {
|
dataMap.set(point.timeHours, {
|
||||||
timeHours: point.timeHours,
|
timeHours: point.timeHours,
|
||||||
idealDamph: point.damph,
|
combinedDamph: point.damph,
|
||||||
idealLdx: point.ldx
|
combinedLdx: point.ldx
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add deviated profile data
|
// Add template profile data (regular plan only) if provided
|
||||||
deviatedProfile?.forEach((point: any) => {
|
templateProfile?.forEach((point: any) => {
|
||||||
const existing = dataMap.get(point.timeHours) || { timeHours: point.timeHours };
|
const existing = dataMap.get(point.timeHours) || { timeHours: point.timeHours };
|
||||||
dataMap.set(point.timeHours, {
|
dataMap.set(point.timeHours, {
|
||||||
...existing,
|
...existing,
|
||||||
deviatedDamph: point.damph,
|
templateDamph: point.damph,
|
||||||
deviatedLdx: point.ldx
|
templateLdx: point.ldx
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add corrected profile data
|
|
||||||
correctedProfile?.forEach((point: any) => {
|
|
||||||
const existing = dataMap.get(point.timeHours) || { timeHours: point.timeHours };
|
|
||||||
dataMap.set(point.timeHours, {
|
|
||||||
...existing,
|
|
||||||
correctedDamph: point.damph,
|
|
||||||
correctedLdx: point.ldx
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(dataMap.values()).sort((a, b) => a.timeHours - b.timeHours);
|
return Array.from(dataMap.values()).sort((a, b) => a.timeHours - b.timeHours);
|
||||||
}, [idealProfile, deviatedProfile, correctedProfile]);
|
}, [combinedProfile, templateProfile]);
|
||||||
|
|
||||||
|
// Calculate chart dimensions
|
||||||
|
const [containerWidth, setContainerWidth] = React.useState(1000);
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const updateWidth = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
setContainerWidth(containerRef.current.clientWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateWidth();
|
||||||
|
window.addEventListener('resize', updateWidth);
|
||||||
|
return () => window.removeEventListener('resize', updateWidth);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const simDays = parseInt(simulationDays, 10) || 3;
|
||||||
|
const dispDays = parseInt(displayedDays, 10) || 2;
|
||||||
|
|
||||||
|
// Y-axis takes ~80px, scrollable area gets the rest
|
||||||
|
const yAxisWidth = 80;
|
||||||
|
const scrollableWidth = containerWidth - yAxisWidth;
|
||||||
|
|
||||||
|
// Calculate chart width for scrollable area
|
||||||
|
const chartWidth = simDays <= dispDays
|
||||||
|
? scrollableWidth
|
||||||
|
: Math.ceil((scrollableWidth / dispDays) * simDays);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-grow w-full overflow-x-auto overflow-y-hidden">
|
<div ref={containerRef} className="flex-grow w-full flex flex-col overflow-y-hidden">
|
||||||
<div style={{ width: `${chartWidthPercentage}%`, height: '100%', minWidth: '100%' }}>
|
{/* Fixed Legend at top */}
|
||||||
|
<div style={{ height: 40, marginBottom: 8, paddingLeft: yAxisWidth + 10 }}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={mergedData} margin={{ top: 20, right: 20, left: 0, bottom: 5 }}>
|
<LineChart data={mergedData} margin={{ top: 0, right: 20, left: 0, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<Legend
|
||||||
|
verticalAlign="top"
|
||||||
|
align="left"
|
||||||
|
height={36}
|
||||||
|
wrapperStyle={{ paddingLeft: 0 }}
|
||||||
|
/>
|
||||||
|
{/* Invisible lines just to show in legend */}
|
||||||
|
{(chartView === 'damph' || chartView === 'both') && (
|
||||||
|
<Line
|
||||||
|
dataKey="combinedDamph"
|
||||||
|
name={`${t.dAmphetamine}`}
|
||||||
|
stroke={CHART_COLORS.idealDamph}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
dot={false}
|
||||||
|
strokeOpacity={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(chartView === 'ldx' || chartView === 'both') && (
|
||||||
|
<Line
|
||||||
|
dataKey="combinedLdx"
|
||||||
|
name={`${t.lisdexamfetamine}`}
|
||||||
|
stroke={CHART_COLORS.idealLdx}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
dot={false}
|
||||||
|
strokeOpacity={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{templateProfile && (chartView === 'damph' || chartView === 'both') && (
|
||||||
|
<Line
|
||||||
|
dataKey="templateDamph"
|
||||||
|
name={`${t.dAmphetamine} (${t.regularPlan} ${t.continuation || 'continuation'})`}
|
||||||
|
stroke={CHART_COLORS.idealDamph}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
dot={false}
|
||||||
|
strokeOpacity={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{templateProfile && (chartView === 'ldx' || chartView === 'both') && (
|
||||||
|
<Line
|
||||||
|
dataKey="templateLdx"
|
||||||
|
name={`${t.lisdexamfetamine} (${t.regularPlan} ${t.continuation || 'continuation'})`}
|
||||||
|
stroke={CHART_COLORS.idealLdx}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
dot={false}
|
||||||
|
strokeOpacity={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<div className="flex-grow flex overflow-y-hidden">
|
||||||
|
{/* Scrollable chart area */}
|
||||||
|
<div className="flex-grow overflow-x-auto overflow-y-hidden">
|
||||||
|
<div style={{ width: chartWidth, height: '100%' }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart
|
||||||
|
data={mergedData}
|
||||||
|
margin={{ top: 0, right: 20, left: 0, bottom: 5 }}
|
||||||
|
syncId="medPlanChart"
|
||||||
|
>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timeHours"
|
dataKey="timeHours"
|
||||||
type="number"
|
type="number"
|
||||||
domain={[0, totalHours]}
|
domain={[0, totalHours]}
|
||||||
ticks={chartTicks}
|
ticks={chartTicks}
|
||||||
|
tickCount={chartTicks.length}
|
||||||
|
interval={0}
|
||||||
tickFormatter={(h) => {
|
tickFormatter={(h) => {
|
||||||
if (showDayTimeOnXAxis) {
|
if (showDayTimeOnXAxis) {
|
||||||
// Show 24h repeating format (0-23h)
|
// Show 24h repeating format (0-23h)
|
||||||
@@ -126,19 +208,25 @@ const SimulationChart = ({
|
|||||||
xAxisId="hours"
|
xAxisId="hours"
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
label={{ value: t.concentration, angle: -90, position: 'insideLeft', offset: -10 }}
|
yAxisId="concentration"
|
||||||
domain={chartDomain as any}
|
//label={{ value: t.concentration, angle: -90, position: 'insideLeft', offset: -10 }}
|
||||||
allowDecimals={false}
|
domain={chartDomain as any}
|
||||||
/>
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: any, name) => [`${typeof value === 'number' ? value.toFixed(1) : value} ${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, payload) => {
|
||||||
|
// Extract timeHours from the payload data point
|
||||||
|
const timeHours = payload?.[0]?.payload?.timeHours ?? label;
|
||||||
|
return `${t.hour.replace('h', 'Hour')}: ${timeHours}${t.hour}`;
|
||||||
|
}}
|
||||||
wrapperStyle={{ pointerEvents: 'none', zIndex: 200 }}
|
wrapperStyle={{ pointerEvents: 'none', zIndex: 200 }}
|
||||||
allowEscapeViewBox={{ x: false, y: false }}
|
allowEscapeViewBox={{ x: false, y: false }}
|
||||||
cursor={{ stroke: CHART_COLORS.cursor, strokeWidth: 1, strokeDasharray: '1 1' }}
|
cursor={{ stroke: CHART_COLORS.cursor, strokeWidth: 1, strokeDasharray: '1 1' }}
|
||||||
position={{ y: 0 }}
|
position={{ y: 0 }}
|
||||||
/>
|
/>
|
||||||
<Legend verticalAlign="top" align="left" height={36} wrapperStyle={{ zIndex: 100, marginLeft: 60 }} />
|
<CartesianGrid strokeDasharray="1 1" xAxisId="hours" yAxisId="concentration" />
|
||||||
|
|
||||||
|
|
||||||
{(chartView === 'damph' || chartView === 'both') && (
|
{(chartView === 'damph' || chartView === 'both') && (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
@@ -147,6 +235,7 @@ const SimulationChart = ({
|
|||||||
stroke={CHART_COLORS.therapeuticMin}
|
stroke={CHART_COLORS.therapeuticMin}
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
xAxisId="hours"
|
xAxisId="hours"
|
||||||
|
yAxisId="concentration"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(chartView === 'damph' || chartView === 'both') && (
|
{(chartView === 'damph' || chartView === 'both') && (
|
||||||
@@ -156,10 +245,11 @@ const SimulationChart = ({
|
|||||||
stroke={CHART_COLORS.therapeuticMax}
|
stroke={CHART_COLORS.therapeuticMax}
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
xAxisId="hours"
|
xAxisId="hours"
|
||||||
|
yAxisId="concentration"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{[...Array(parseInt(simulationDays, 10) || 0).keys()].map(day => (
|
{[...Array(parseInt(simulationDays, 10) || 3).keys()].map(day => (
|
||||||
day > 0 && (
|
day > 0 && (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
key={day}
|
key={day}
|
||||||
@@ -174,85 +264,68 @@ const SimulationChart = ({
|
|||||||
{(chartView === 'damph' || chartView === 'both') && (
|
{(chartView === 'damph' || chartView === 'both') && (
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="idealDamph"
|
dataKey="combinedDamph"
|
||||||
name={`${t.dAmphetamine} (Ideal)`}
|
name={`${t.dAmphetamine}`}
|
||||||
stroke={CHART_COLORS.idealDamph}
|
stroke={CHART_COLORS.idealDamph}
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
dot={false}
|
dot={false}
|
||||||
xAxisId="hours"
|
xAxisId="hours"
|
||||||
|
yAxisId="concentration"
|
||||||
connectNulls
|
connectNulls
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(chartView === 'ldx' || chartView === 'both') && (
|
{(chartView === 'ldx' || chartView === 'both') && (
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="idealLdx"
|
dataKey="combinedLdx"
|
||||||
name={`${t.lisdexamfetamine} (Ideal)`}
|
name={`${t.lisdexamfetamine}`}
|
||||||
stroke={CHART_COLORS.idealLdx}
|
stroke={CHART_COLORS.idealLdx}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
xAxisId="hours"
|
xAxisId="hours"
|
||||||
|
yAxisId="concentration"
|
||||||
connectNulls
|
connectNulls
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{deviatedProfile && (chartView === 'damph' || chartView === 'both') && (
|
{templateProfile && (chartView === 'damph' || chartView === 'both') && (
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="deviatedDamph"
|
dataKey="templateDamph"
|
||||||
name={`${t.dAmphetamine} (Deviation)`}
|
name={`${t.dAmphetamine} (${t.regularPlan} ${t.continuation || 'continuation'})`}
|
||||||
stroke={CHART_COLORS.deviatedDamph}
|
stroke={CHART_COLORS.idealDamph}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
strokeDasharray="5 5"
|
strokeDasharray="3 3"
|
||||||
dot={false}
|
dot={false}
|
||||||
xAxisId="hours"
|
xAxisId="hours"
|
||||||
|
yAxisId="concentration"
|
||||||
connectNulls
|
connectNulls
|
||||||
|
strokeOpacity={0.5}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{deviatedProfile && (chartView === 'ldx' || chartView === 'both') && (
|
{templateProfile && (chartView === 'ldx' || chartView === 'both') && (
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="deviatedLdx"
|
dataKey="templateLdx"
|
||||||
name={`${t.lisdexamfetamine} (Deviation)`}
|
name={`${t.lisdexamfetamine} (${t.regularPlan} ${t.continuation || 'continuation'})`}
|
||||||
stroke={CHART_COLORS.deviatedLdx}
|
stroke={CHART_COLORS.idealLdx}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
strokeDasharray="5 5"
|
strokeDasharray="3 3"
|
||||||
dot={false}
|
dot={false}
|
||||||
xAxisId="hours"
|
xAxisId="hours"
|
||||||
|
yAxisId="concentration"
|
||||||
connectNulls
|
connectNulls
|
||||||
|
strokeOpacity={0.5}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</LineChart>
|
||||||
{correctedProfile && (chartView === 'damph' || chartView === 'both') && (
|
</ResponsiveContainer>
|
||||||
<Line
|
</div>
|
||||||
type="monotone"
|
</div>
|
||||||
dataKey="correctedDamph"
|
|
||||||
name={`${t.dAmphetamine} (Correction)`}
|
|
||||||
stroke={CHART_COLORS.correctedDamph}
|
|
||||||
strokeWidth={2.5}
|
|
||||||
strokeDasharray="3 7"
|
|
||||||
dot={false}
|
|
||||||
xAxisId="hours"
|
|
||||||
connectNulls
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{correctedProfile && (chartView === 'ldx' || chartView === 'both') && (
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="correctedLdx"
|
|
||||||
name={`${t.lisdexamfetamine} (Correction)`}
|
|
||||||
stroke={CHART_COLORS.correctedLdx}
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeDasharray="3 7"
|
|
||||||
dot={false}
|
|
||||||
xAxisId="hours"
|
|
||||||
connectNulls
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};export default SimulationChart;
|
};
|
||||||
|
|
||||||
|
export default SimulationChart;
|
||||||
|
|||||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -142,7 +142,7 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 w-9 rounded-r-none",
|
"h-9 w-9 rounded-r-none border-r-0",
|
||||||
hasError && "border-destructive"
|
hasError && "border-destructive"
|
||||||
)}
|
)}
|
||||||
onClick={() => updateValue(-1)}
|
onClick={() => updateValue(-1)}
|
||||||
@@ -159,14 +159,28 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-24",
|
"w-20 h-9 z-20",
|
||||||
"rounded-none border-x-0 h-9",
|
"rounded-none",
|
||||||
getAlignmentClass(),
|
getAlignmentClass(),
|
||||||
hasError && "border-destructive focus-visible:ring-destructive"
|
hasError && "border-destructive focus-visible:ring-destructive"
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{clearButton && allowEmpty ? (
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-9",
|
||||||
|
clearButton && allowEmpty ? "rounded-l-none rounded-r-none border-x-0" : "rounded-l-none border-l-0",
|
||||||
|
hasError && "border-destructive"
|
||||||
|
)}
|
||||||
|
onClick={() => updateValue(1)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{clearButton && allowEmpty && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -180,20 +194,6 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
|
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
|
||||||
|
|||||||
@@ -18,13 +18,25 @@ import { cn } from "../../lib/utils"
|
|||||||
interface TimeInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
|
interface TimeInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
|
unit?: string
|
||||||
|
align?: 'left' | 'center' | 'right'
|
||||||
error?: boolean
|
error?: boolean
|
||||||
required?: boolean
|
required?: boolean
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
|
const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
|
||||||
({ value, onChange, error = false, required = false, errorMessage = 'Time is required', className, ...props }, ref) => {
|
({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
unit,
|
||||||
|
align = 'center',
|
||||||
|
error = false,
|
||||||
|
required = false,
|
||||||
|
errorMessage = 'Time is required',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
const [displayValue, setDisplayValue] = React.useState(value)
|
const [displayValue, setDisplayValue] = React.useState(value)
|
||||||
const [isPickerOpen, setIsPickerOpen] = React.useState(false)
|
const [isPickerOpen, setIsPickerOpen] = React.useState(false)
|
||||||
const [showError, setShowError] = React.useState(false)
|
const [showError, setShowError] = React.useState(false)
|
||||||
@@ -94,78 +106,96 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
|
|||||||
onChange(formattedTime)
|
onChange(formattedTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAlignmentClass = () => {
|
||||||
|
switch (align) {
|
||||||
|
case 'left': return 'text-left'
|
||||||
|
case 'center': return 'text-center'
|
||||||
|
case 'right': return 'text-right'
|
||||||
|
default: return 'text-right'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={cn("relative flex items-center gap-2", className)}>
|
<div ref={containerRef} className={cn("relative flex items-center gap-2", className)}>
|
||||||
<Input
|
<div className="flex items-center">
|
||||||
ref={ref}
|
<Input
|
||||||
type="text"
|
ref={ref}
|
||||||
value={displayValue}
|
type="text"
|
||||||
onChange={handleChange}
|
value={displayValue}
|
||||||
onBlur={handleBlur}
|
onChange={handleChange}
|
||||||
onFocus={handleFocus}
|
onBlur={handleBlur}
|
||||||
placeholder="HH:MM"
|
onFocus={handleFocus}
|
||||||
className={cn(
|
placeholder="HH:MM"
|
||||||
"w-24",
|
className={cn(
|
||||||
hasError && "border-destructive focus-visible:ring-destructive"
|
"w-20 h-9 z-20",
|
||||||
)}
|
"rounded-r-none",
|
||||||
{...props}
|
getAlignmentClass(),
|
||||||
/>
|
hasError && "border-destructive focus-visible:ring-destructive"
|
||||||
<Popover open={isPickerOpen} onOpenChange={setIsPickerOpen}>
|
)}
|
||||||
<PopoverTrigger asChild>
|
{...props}
|
||||||
<Button
|
/>
|
||||||
type="button"
|
<Popover open={isPickerOpen} onOpenChange={setIsPickerOpen}>
|
||||||
variant="outline"
|
<PopoverTrigger asChild>
|
||||||
size="icon"
|
<Button
|
||||||
className={cn(hasError && "border-destructive")}
|
type="button"
|
||||||
>
|
variant="outline"
|
||||||
<Clock className="h-4 w-4" />
|
size="icon"
|
||||||
</Button>
|
className={cn(
|
||||||
</PopoverTrigger>
|
"h-9 w-9",
|
||||||
<PopoverContent className="w-auto p-3 bg-popover shadow-md border">
|
"rounded-l-none border-l-0",
|
||||||
<div className="flex gap-2">
|
hasError && "border-destructive")}
|
||||||
<div className="flex flex-col gap-1">
|
tabIndex={-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">
|
<Clock className="h-4 w-4" />
|
||||||
{Array.from({ length: 24 }, (_, i) => (
|
</Button>
|
||||||
<Button
|
</PopoverTrigger>
|
||||||
key={i}
|
<PopoverContent className="w-auto p-3 bg-popover shadow-md border">
|
||||||
type="button"
|
<div className="flex gap-2">
|
||||||
variant={pickerHours === i ? "default" : "outline"}
|
<div className="flex flex-col gap-1">
|
||||||
size="sm"
|
<div className="text-xs font-medium text-center mb-1">Hour</div>
|
||||||
className="h-8 w-10"
|
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto">
|
||||||
onClick={() => {
|
{Array.from({ length: 24 }, (_, i) => (
|
||||||
handlePickerChange('h', i)
|
<Button
|
||||||
setIsPickerOpen(false)
|
key={i}
|
||||||
}}
|
type="button"
|
||||||
>
|
variant={pickerHours === i ? "default" : "outline"}
|
||||||
{String(i).padStart(2, '0')}
|
size="sm"
|
||||||
</Button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
</PopoverContent>
|
||||||
<div className="text-xs font-medium text-center mb-1">Min</div>
|
</Popover>
|
||||||
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto">
|
</div>
|
||||||
{Array.from({ length: 12 }, (_, i) => i * 5).map(minute => (
|
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
|
||||||
<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 && (
|
{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">
|
<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}
|
{errorMessage}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v5';
|
export const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v6';
|
||||||
export const LDX_TO_DAMPH_CONVERSION_FACTOR = 0.2948;
|
export const LDX_TO_DAMPH_CONVERSION_FACTOR = 0.2948;
|
||||||
|
|
||||||
// Type definitions
|
// Type definitions
|
||||||
@@ -17,15 +17,17 @@ export interface PkParams {
|
|||||||
ldx: { halfLife: string; absorptionRate: string };
|
ldx: { halfLife: string; absorptionRate: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Dose {
|
export interface DayDose {
|
||||||
|
id: string;
|
||||||
time: string;
|
time: string;
|
||||||
dose: string;
|
ldx: string;
|
||||||
label: string;
|
damph?: string; // Optional, kept for backwards compatibility but not used in UI
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Deviation extends Dose {
|
export interface DayGroup {
|
||||||
dayOffset?: number;
|
id: string;
|
||||||
isAdditional: boolean;
|
isTemplate: boolean;
|
||||||
|
doses: DayDose[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SteadyStateConfig {
|
export interface SteadyStateConfig {
|
||||||
@@ -39,7 +41,8 @@ export interface TherapeuticRange {
|
|||||||
|
|
||||||
export interface UiSettings {
|
export interface UiSettings {
|
||||||
showDayTimeOnXAxis: boolean;
|
showDayTimeOnXAxis: boolean;
|
||||||
chartView: 'damph' | 'ldx' | 'both';
|
showTemplateDay: boolean;
|
||||||
|
chartView: 'ldx' | 'damph' | 'both';
|
||||||
yAxisMin: string;
|
yAxisMin: string;
|
||||||
yAxisMax: string;
|
yAxisMax: string;
|
||||||
simulationDays: string;
|
simulationDays: string;
|
||||||
@@ -48,13 +51,25 @@ export interface UiSettings {
|
|||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
pkParams: PkParams;
|
pkParams: PkParams;
|
||||||
doses: Dose[];
|
days: DayGroup[];
|
||||||
steadyStateConfig: SteadyStateConfig;
|
steadyStateConfig: SteadyStateConfig;
|
||||||
therapeuticRange: TherapeuticRange;
|
therapeuticRange: TherapeuticRange;
|
||||||
doseIncrement: string;
|
doseIncrement: string;
|
||||||
uiSettings: UiSettings;
|
uiSettings: UiSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy interfaces for backwards compatibility (will be removed later)
|
||||||
|
export interface Dose {
|
||||||
|
time: string;
|
||||||
|
dose: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Deviation extends Dose {
|
||||||
|
dayOffset?: number;
|
||||||
|
isAdditional: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConcentrationPoint {
|
export interface ConcentrationPoint {
|
||||||
timeHours: number;
|
timeHours: number;
|
||||||
ldx: number;
|
ldx: number;
|
||||||
@@ -67,18 +82,24 @@ export const getDefaultState = (): AppState => ({
|
|||||||
damph: { halfLife: '11' },
|
damph: { halfLife: '11' },
|
||||||
ldx: { halfLife: '0.8', absorptionRate: '1.5' },
|
ldx: { halfLife: '0.8', absorptionRate: '1.5' },
|
||||||
},
|
},
|
||||||
doses: [
|
days: [
|
||||||
{ time: '06:30', dose: '25', label: 'morning' },
|
{
|
||||||
{ time: '12:30', dose: '10', label: 'midday' },
|
id: 'day-template',
|
||||||
{ time: '17:00', dose: '10', label: 'afternoon' },
|
isTemplate: true,
|
||||||
{ time: '22:00', dose: '10', label: 'evening' },
|
doses: [
|
||||||
{ time: '01:00', dose: '0', label: 'night' },
|
{ id: 'dose-1', time: '06:30', ldx: '25' },
|
||||||
|
{ id: 'dose-2', time: '12:30', ldx: '10' },
|
||||||
|
{ id: 'dose-3', time: '17:00', ldx: '10' },
|
||||||
|
{ id: 'dose-4', time: '22:00', ldx: '10' },
|
||||||
|
]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
steadyStateConfig: { daysOnMedication: '7' },
|
steadyStateConfig: { daysOnMedication: '7' },
|
||||||
therapeuticRange: { min: '10.5', max: '11.5' },
|
therapeuticRange: { min: '10.5', max: '11.5' },
|
||||||
doseIncrement: '2.5',
|
doseIncrement: '2.5',
|
||||||
uiSettings: {
|
uiSettings: {
|
||||||
showDayTimeOnXAxis: true,
|
showDayTimeOnXAxis: true,
|
||||||
|
showTemplateDay: false,
|
||||||
chartView: 'both',
|
chartView: 'both',
|
||||||
yAxisMin: '0',
|
yAxisMin: '0',
|
||||||
yAxisMax: '16',
|
yAxisMax: '16',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { LOCAL_STORAGE_KEY, getDefaultState, type AppState } from '../constants/defaults';
|
import { LOCAL_STORAGE_KEY, getDefaultState, type AppState, type DayGroup, type DayDose } from '../constants/defaults';
|
||||||
|
|
||||||
export const useAppState = () => {
|
export const useAppState = () => {
|
||||||
const [appState, setAppState] = React.useState<AppState>(getDefaultState);
|
const [appState, setAppState] = React.useState<AppState>(getDefaultState);
|
||||||
@@ -26,6 +26,7 @@ export const useAppState = () => {
|
|||||||
...defaults,
|
...defaults,
|
||||||
...parsedState,
|
...parsedState,
|
||||||
pkParams: {...defaults.pkParams, ...parsedState.pkParams},
|
pkParams: {...defaults.pkParams, ...parsedState.pkParams},
|
||||||
|
days: parsedState.days || defaults.days,
|
||||||
uiSettings: {...defaults.uiSettings, ...parsedState.uiSettings},
|
uiSettings: {...defaults.uiSettings, ...parsedState.uiSettings},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,7 @@ export const useAppState = () => {
|
|||||||
try {
|
try {
|
||||||
const stateToSave = {
|
const stateToSave = {
|
||||||
pkParams: appState.pkParams,
|
pkParams: appState.pkParams,
|
||||||
doses: appState.doses,
|
days: appState.days,
|
||||||
steadyStateConfig: appState.steadyStateConfig,
|
steadyStateConfig: appState.steadyStateConfig,
|
||||||
therapeuticRange: appState.therapeuticRange,
|
therapeuticRange: appState.therapeuticRange,
|
||||||
doseIncrement: appState.doseIncrement,
|
doseIncrement: appState.doseIncrement,
|
||||||
@@ -72,15 +73,119 @@ export const useAppState = () => {
|
|||||||
key: K,
|
key: K,
|
||||||
value: AppState['uiSettings'][K]
|
value: AppState['uiSettings'][K]
|
||||||
) => {
|
) => {
|
||||||
const newUiSettings = { ...appState.uiSettings, [key]: value };
|
setAppState(prev => {
|
||||||
if (key === 'simulationDays') {
|
const newUiSettings = { ...prev.uiSettings, [key]: value };
|
||||||
const simDaysNum = parseInt(value as string, 10) || 1;
|
|
||||||
const dispDaysNum = parseInt(newUiSettings.displayedDays, 10) || 1;
|
// Auto-adjust displayedDays if simulationDays is reduced
|
||||||
if (dispDaysNum > simDaysNum) {
|
if (key === 'simulationDays') {
|
||||||
newUiSettings.displayedDays = String(simDaysNum);
|
const simDays = parseInt(value as string, 10) || 3;
|
||||||
|
const dispDays = parseInt(prev.uiSettings.displayedDays, 10) || 2;
|
||||||
|
if (dispDays > simDays) {
|
||||||
|
newUiSettings.displayedDays = String(simDays);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
setAppState(prev => ({ ...prev, uiSettings: newUiSettings }));
|
return { ...prev, uiSettings: newUiSettings };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Day management functions
|
||||||
|
const addDay = (cloneFromDayId?: string) => {
|
||||||
|
const maxDays = 3; // Template + 2 deviation days
|
||||||
|
if (appState.days.length >= maxDays) return;
|
||||||
|
|
||||||
|
const sourceDay = cloneFromDayId
|
||||||
|
? appState.days.find(d => d.id === cloneFromDayId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const newDay: DayGroup = sourceDay
|
||||||
|
? {
|
||||||
|
id: `day-${Date.now()}`,
|
||||||
|
isTemplate: false,
|
||||||
|
doses: sourceDay.doses.map(d => ({
|
||||||
|
id: `dose-${Date.now()}-${Math.random()}`,
|
||||||
|
time: d.time,
|
||||||
|
ldx: d.ldx
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: `day-${Date.now()}`,
|
||||||
|
isTemplate: false,
|
||||||
|
doses: [{ id: `dose-${Date.now()}`, time: '12:00', ldx: '30' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
setAppState(prev => ({ ...prev, days: [...prev.days, newDay] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDay = (dayId: string) => {
|
||||||
|
setAppState(prev => {
|
||||||
|
const dayToRemove = prev.days.find(d => d.id === dayId);
|
||||||
|
// Never delete template day
|
||||||
|
if (dayToRemove?.isTemplate) {
|
||||||
|
console.warn('Cannot delete template day');
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
// Never delete if it would leave us with no days
|
||||||
|
if (prev.days.length <= 1) {
|
||||||
|
console.warn('Cannot delete last day');
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return { ...prev, days: prev.days.filter(d => d.id !== dayId) };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDay = (dayId: string, updatedDay: DayGroup) => {
|
||||||
|
setAppState(prev => ({
|
||||||
|
...prev,
|
||||||
|
days: prev.days.map(day => day.id === dayId ? updatedDay : day)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDoseToDay = (dayId: string, newDose?: Partial<DayDose>) => {
|
||||||
|
setAppState(prev => ({
|
||||||
|
...prev,
|
||||||
|
days: prev.days.map(day => {
|
||||||
|
if (day.id !== dayId) return day;
|
||||||
|
if (day.doses.length >= 5) return day; // Max 5 doses per day
|
||||||
|
|
||||||
|
const dose: DayDose = {
|
||||||
|
id: `dose-${Date.now()}-${Math.random()}`,
|
||||||
|
time: newDose?.time || '12:00',
|
||||||
|
ldx: newDose?.ldx || '0',
|
||||||
|
damph: newDose?.damph || '0',
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...day, doses: [...day.doses, dose] };
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDoseFromDay = (dayId: string, doseId: string) => {
|
||||||
|
setAppState(prev => ({
|
||||||
|
...prev,
|
||||||
|
days: prev.days.map(day => {
|
||||||
|
if (day.id !== dayId) return day;
|
||||||
|
// Don't allow removing last dose from template day
|
||||||
|
if (day.isTemplate && day.doses.length <= 1) return day;
|
||||||
|
|
||||||
|
return { ...day, doses: day.doses.filter(dose => dose.id !== doseId) };
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDoseInDay = (dayId: string, doseId: string, field: keyof DayDose, value: string) => {
|
||||||
|
setAppState(prev => ({
|
||||||
|
...prev,
|
||||||
|
days: prev.days.map(day => {
|
||||||
|
if (day.id !== dayId) return day;
|
||||||
|
return {
|
||||||
|
...day,
|
||||||
|
doses: day.doses.map(dose =>
|
||||||
|
dose.id === doseId ? { ...dose, [field]: value } : dose
|
||||||
|
)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
@@ -96,6 +201,12 @@ export const useAppState = () => {
|
|||||||
updateState,
|
updateState,
|
||||||
updateNestedState,
|
updateNestedState,
|
||||||
updateUiSetting,
|
updateUiSetting,
|
||||||
|
addDay,
|
||||||
|
removeDay,
|
||||||
|
updateDay,
|
||||||
|
addDoseToDay,
|
||||||
|
removeDoseFromDay,
|
||||||
|
updateDoseInDay,
|
||||||
handleReset
|
handleReset
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Simulation Hook
|
* Simulation Hook
|
||||||
*
|
*
|
||||||
* Manages pharmacokinetic simulation calculations and deviation handling.
|
* Manages pharmacokinetic simulation calculations for day-based plans.
|
||||||
* Computes ideal, deviated, and corrected concentration profiles.
|
* Computes concentration profiles from all days in the schedule.
|
||||||
* Generates dose correction suggestions based on deviations.
|
|
||||||
*
|
*
|
||||||
* @author Andreas Weyer
|
* @author Andreas Weyer
|
||||||
* @license MIT
|
* @license MIT
|
||||||
@@ -11,134 +10,81 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { calculateCombinedProfile } from '../utils/calculations';
|
import { calculateCombinedProfile } from '../utils/calculations';
|
||||||
import { generateSuggestion } from '../utils/suggestions';
|
import type { AppState } from '../constants/defaults';
|
||||||
import { timeToMinutes } from '../utils/timeUtils';
|
|
||||||
import type { AppState, Deviation } from '../constants/defaults';
|
|
||||||
|
|
||||||
interface SuggestionResult {
|
export const useSimulation = (appState: AppState) => {
|
||||||
text?: string;
|
const { pkParams, days, steadyStateConfig, uiSettings } = appState;
|
||||||
time?: string;
|
const { showTemplateDay, simulationDays } = uiSettings;
|
||||||
dose?: string;
|
|
||||||
isAdditional?: boolean;
|
|
||||||
originalDose?: string;
|
|
||||||
dayOffset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Translations {
|
// Extend days to match simulation duration
|
||||||
noSuitableNextDose: string;
|
const extendedDays = React.useMemo(() => {
|
||||||
noSignificantCorrection: string;
|
const numSimDays = parseInt(simulationDays, 10) || 3;
|
||||||
}
|
if (days.length >= numSimDays) return days;
|
||||||
|
|
||||||
export const useSimulation = (appState: AppState, t: Translations) => {
|
// Repeat template day to fill simulation period
|
||||||
const { pkParams, doses, steadyStateConfig, doseIncrement, uiSettings } = appState;
|
const templateDay = days.find(d => d.isTemplate);
|
||||||
const { simulationDays } = uiSettings;
|
if (!templateDay) return days;
|
||||||
|
|
||||||
const [deviations, setDeviations] = React.useState<Deviation[]>([]);
|
const extended = [...days];
|
||||||
const [suggestion, setSuggestion] = React.useState<SuggestionResult | null>(null);
|
for (let i = days.length; i < numSimDays; i++) {
|
||||||
|
extended.push({
|
||||||
const calculateCombinedProfileMemo = React.useCallback(
|
id: `extended-day-${i}`,
|
||||||
(doseSchedule = doses, deviationList: Deviation[] = [], correction: Deviation | null = null) =>
|
isTemplate: false,
|
||||||
calculateCombinedProfile(
|
doses: templateDay.doses.map(d => ({
|
||||||
doseSchedule,
|
id: `${d.id}-ext-${i}`,
|
||||||
deviationList,
|
time: d.time,
|
||||||
correction,
|
ldx: d.ldx
|
||||||
steadyStateConfig,
|
}))
|
||||||
simulationDays,
|
});
|
||||||
pkParams
|
|
||||||
),
|
|
||||||
[doses, steadyStateConfig, simulationDays, pkParams]
|
|
||||||
);
|
|
||||||
|
|
||||||
const generateSuggestionMemo = React.useCallback(() => {
|
|
||||||
const newSuggestion = generateSuggestion(
|
|
||||||
doses,
|
|
||||||
deviations,
|
|
||||||
doseIncrement,
|
|
||||||
simulationDays,
|
|
||||||
steadyStateConfig,
|
|
||||||
pkParams,
|
|
||||||
t
|
|
||||||
);
|
|
||||||
setSuggestion(newSuggestion);
|
|
||||||
}, [doses, deviations, doseIncrement, simulationDays, steadyStateConfig, pkParams, t]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
generateSuggestionMemo();
|
|
||||||
}, [generateSuggestionMemo]);
|
|
||||||
|
|
||||||
const idealProfile = React.useMemo(() =>
|
|
||||||
calculateCombinedProfileMemo(doses),
|
|
||||||
[doses, calculateCombinedProfileMemo]
|
|
||||||
);
|
|
||||||
|
|
||||||
const deviatedProfile = React.useMemo(() =>
|
|
||||||
deviations.length > 0 ? calculateCombinedProfileMemo(doses, deviations) : null,
|
|
||||||
[doses, deviations, calculateCombinedProfileMemo]
|
|
||||||
);
|
|
||||||
|
|
||||||
const correctedProfile = React.useMemo(() =>
|
|
||||||
suggestion && suggestion.dose ? calculateCombinedProfileMemo(doses, deviations, suggestion as Deviation) : null,
|
|
||||||
[doses, deviations, suggestion, calculateCombinedProfileMemo]
|
|
||||||
);
|
|
||||||
|
|
||||||
const addDeviation = () => {
|
|
||||||
const templateDose = { time: '07:00', dose: '10', label: '' };
|
|
||||||
const sortedDoses = [...doses].sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time));
|
|
||||||
let nextDose: any = sortedDoses[0] || templateDose;
|
|
||||||
let nextDayOffset = 0;
|
|
||||||
|
|
||||||
if (deviations.length > 0) {
|
|
||||||
const lastDev = deviations[deviations.length - 1];
|
|
||||||
const lastDevTime = timeToMinutes(lastDev.time) + (lastDev.dayOffset || 0) * 24 * 60;
|
|
||||||
const nextPlanned = sortedDoses.find(d => timeToMinutes(d.time) > (lastDevTime % (24*60)));
|
|
||||||
if (nextPlanned) {
|
|
||||||
nextDose = nextPlanned;
|
|
||||||
nextDayOffset = lastDev.dayOffset || 0;
|
|
||||||
} else {
|
|
||||||
nextDose = sortedDoses[0];
|
|
||||||
nextDayOffset = (lastDev.dayOffset || 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return extended;
|
||||||
|
}, [days, simulationDays]);
|
||||||
|
|
||||||
// Use templateDose if nextDose has no time
|
// Calculate profile with extended days
|
||||||
if (!nextDose.time || nextDose.time === '') {
|
const combinedProfile = React.useMemo(() => {
|
||||||
nextDose = templateDose;
|
if (extendedDays.length === 0) return [];
|
||||||
|
return calculateCombinedProfile(extendedDays, steadyStateConfig, pkParams);
|
||||||
|
}, [extendedDays, steadyStateConfig, pkParams]);
|
||||||
|
|
||||||
|
// Filter visible days for display purposes only
|
||||||
|
const visibleDays = React.useMemo(() => {
|
||||||
|
if (showTemplateDay) {
|
||||||
|
return days;
|
||||||
}
|
}
|
||||||
|
// Show only non-template days
|
||||||
|
return days.filter(day => !day.isTemplate);
|
||||||
|
}, [days, showTemplateDay]);
|
||||||
|
|
||||||
setDeviations([...deviations, {
|
// Calculate template continuation profile (day 2 onwards for comparison)
|
||||||
time: nextDose.time,
|
const templateProfile = React.useMemo(() => {
|
||||||
dose: nextDose.dose,
|
if (!showTemplateDay) return null;
|
||||||
label: nextDose.label || '',
|
|
||||||
isAdditional: false,
|
|
||||||
dayOffset: nextDayOffset
|
|
||||||
}]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeDeviation = (index: number) => {
|
const templateDay = days.find(day => day.isTemplate);
|
||||||
setDeviations(deviations.filter((_, i) => i !== index));
|
if (!templateDay) return null;
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeviationChange = (index: number, field: keyof Deviation, value: any) => {
|
const numSimDays = parseInt(simulationDays, 10) || 3;
|
||||||
const newDeviations = [...deviations];
|
if (numSimDays < 2) return null; // Need at least 2 days to show continuation
|
||||||
(newDeviations[index] as any)[field] = value;
|
|
||||||
setDeviations(newDeviations);
|
|
||||||
};
|
|
||||||
|
|
||||||
const applySuggestion = () => {
|
// Create array with template day repeated for entire simulation period
|
||||||
if (!suggestion || !suggestion.dose) return;
|
const templateDays = Array.from({ length: numSimDays }, (_, i) => ({
|
||||||
setDeviations([...deviations, suggestion as Deviation]);
|
id: `template-continuation-${i}`,
|
||||||
setSuggestion(null);
|
isTemplate: false,
|
||||||
};
|
doses: templateDay.doses.map(d => ({
|
||||||
|
id: `${d.id}-template-${i}`,
|
||||||
|
time: d.time,
|
||||||
|
ldx: d.ldx
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fullProfile = calculateCombinedProfile(templateDays, steadyStateConfig, pkParams);
|
||||||
|
|
||||||
|
// Filter to only show from day 2 onwards (skip first 24 hours)
|
||||||
|
return fullProfile.filter(point => point.timeHours >= 24);
|
||||||
|
}, [days, steadyStateConfig, pkParams, showTemplateDay, simulationDays]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deviations,
|
combinedProfile,
|
||||||
suggestion,
|
templateProfile,
|
||||||
idealProfile,
|
visibleDays
|
||||||
deviatedProfile,
|
|
||||||
correctedProfile,
|
|
||||||
addDeviation,
|
|
||||||
removeDeviation,
|
|
||||||
handleDeviationChange,
|
|
||||||
applySuggestion
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -77,7 +77,28 @@ export const de = {
|
|||||||
|
|
||||||
// Field validation
|
// Field validation
|
||||||
errorNumberRequired: "Bitte gib eine gültige Zahl ein.",
|
errorNumberRequired: "Bitte gib eine gültige Zahl ein.",
|
||||||
errorTimeRequired: "Bitte gib eine gültige Zeitangabe ein."
|
errorTimeRequired: "Bitte gib eine gültige Zeitangabe ein.",
|
||||||
|
|
||||||
|
// Day-based schedule
|
||||||
|
regularPlan: "Regulärer Plan",
|
||||||
|
continuation: "Fortsetzung",
|
||||||
|
dayNumber: "Tag {{number}}",
|
||||||
|
cloneDay: "Tag klonen",
|
||||||
|
addDay: "Tag hinzufügen",
|
||||||
|
addDose: "Dosis hinzufügen",
|
||||||
|
removeDose: "Dosis entfernen",
|
||||||
|
removeDay: "Tag entfernen",
|
||||||
|
time: "Zeit",
|
||||||
|
ldx: "LDX",
|
||||||
|
damph: "d-amph",
|
||||||
|
|
||||||
|
// URL sharing
|
||||||
|
sharePlan: "Plan teilen",
|
||||||
|
viewingSharedPlan: "Du siehst einen geteilten Plan",
|
||||||
|
saveAsMyPlan: "Als meinen Plan speichern",
|
||||||
|
discardSharedPlan: "Verwerfen",
|
||||||
|
planCopiedToClipboard: "Plan-Link in Zwischenablage kopiert!",
|
||||||
|
showTemplateDayInChart: "Regulären Plan im Diagramm anzeigen"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default de;
|
export default de;
|
||||||
|
|||||||
@@ -77,7 +77,28 @@ export const en = {
|
|||||||
|
|
||||||
// Field validation
|
// Field validation
|
||||||
errorNumberRequired: "Please enter a valid number.",
|
errorNumberRequired: "Please enter a valid number.",
|
||||||
errorTimeRequired: "Please enter a valid time."
|
errorTimeRequired: "Please enter a valid time.",
|
||||||
|
|
||||||
|
// Day-based schedule
|
||||||
|
regularPlan: "Regular Plan",
|
||||||
|
continuation: "continuation",
|
||||||
|
dayNumber: "Day {{number}}",
|
||||||
|
cloneDay: "Clone day",
|
||||||
|
addDay: "Add day",
|
||||||
|
addDose: "Add dose",
|
||||||
|
removeDose: "Remove dose",
|
||||||
|
removeDay: "Remove day",
|
||||||
|
time: "Time",
|
||||||
|
ldx: "LDX",
|
||||||
|
damph: "d-amph",
|
||||||
|
|
||||||
|
// URL sharing
|
||||||
|
sharePlan: "Share Plan",
|
||||||
|
viewingSharedPlan: "You are viewing a shared plan",
|
||||||
|
saveAsMyPlan: "Save as My Plan",
|
||||||
|
discardSharedPlan: "Discard",
|
||||||
|
planCopiedToClipboard: "Plan link copied to clipboard!",
|
||||||
|
showTemplateDayInChart: "Show regular plan in chart"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|||||||
@@ -11,84 +11,80 @@
|
|||||||
|
|
||||||
import { timeToMinutes } from './timeUtils';
|
import { timeToMinutes } from './timeUtils';
|
||||||
import { calculateSingleDoseConcentration } from './pharmacokinetics';
|
import { calculateSingleDoseConcentration } from './pharmacokinetics';
|
||||||
import type { Dose, Deviation, SteadyStateConfig, PkParams, ConcentrationPoint } from '../constants/defaults';
|
import type { DayGroup, SteadyStateConfig, PkParams, ConcentrationPoint } from '../constants/defaults';
|
||||||
|
|
||||||
interface DoseWithTime extends Omit<Dose, 'time'> {
|
interface ProcessedDose {
|
||||||
time: number;
|
timeMinutes: number;
|
||||||
isPlan?: boolean;
|
ldx: number;
|
||||||
|
damph: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const calculateCombinedProfile = (
|
export const calculateCombinedProfile = (
|
||||||
doseSchedule: Dose[],
|
days: DayGroup[],
|
||||||
deviationList: Deviation[] = [],
|
|
||||||
correction: Deviation | null = null,
|
|
||||||
steadyStateConfig: SteadyStateConfig,
|
steadyStateConfig: SteadyStateConfig,
|
||||||
simulationDays: string,
|
|
||||||
pkParams: PkParams
|
pkParams: PkParams
|
||||||
): ConcentrationPoint[] => {
|
): ConcentrationPoint[] => {
|
||||||
const dataPoints: ConcentrationPoint[] = [];
|
const dataPoints: ConcentrationPoint[] = [];
|
||||||
const timeStepHours = 0.25;
|
const timeStepHours = 0.25;
|
||||||
const totalHours = (parseInt(simulationDays, 10) || 3) * 24;
|
const totalDays = days.length;
|
||||||
|
const totalHours = totalDays * 24;
|
||||||
const daysToSimulate = Math.min(parseInt(steadyStateConfig.daysOnMedication, 10) || 0, 5);
|
const daysToSimulate = Math.min(parseInt(steadyStateConfig.daysOnMedication, 10) || 0, 5);
|
||||||
|
|
||||||
|
// Convert days to processed doses with absolute time
|
||||||
|
const allDoses: ProcessedDose[] = [];
|
||||||
|
|
||||||
|
// Add steady-state doses (days before simulation period)
|
||||||
|
// Use template day (first day) for steady state
|
||||||
|
const templateDay = days[0];
|
||||||
|
if (templateDay) {
|
||||||
|
for (let steadyDay = -daysToSimulate; steadyDay < 0; steadyDay++) {
|
||||||
|
const dayOffsetMinutes = steadyDay * 24 * 60;
|
||||||
|
templateDay.doses.forEach(dose => {
|
||||||
|
const ldxNum = parseFloat(dose.ldx);
|
||||||
|
if (dose.time && !isNaN(ldxNum) && ldxNum > 0) {
|
||||||
|
allDoses.push({
|
||||||
|
timeMinutes: timeToMinutes(dose.time) + dayOffsetMinutes,
|
||||||
|
ldx: ldxNum,
|
||||||
|
damph: 0 // d-amph is calculated from LDX conversion, not administered directly
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add doses from each day in sequence
|
||||||
|
days.forEach((day, dayIndex) => {
|
||||||
|
const dayOffsetMinutes = dayIndex * 24 * 60;
|
||||||
|
day.doses.forEach(dose => {
|
||||||
|
const ldxNum = parseFloat(dose.ldx);
|
||||||
|
if (dose.time && !isNaN(ldxNum) && ldxNum > 0) {
|
||||||
|
allDoses.push({
|
||||||
|
timeMinutes: timeToMinutes(dose.time) + dayOffsetMinutes,
|
||||||
|
ldx: ldxNum,
|
||||||
|
damph: 0 // d-amph is calculated from LDX conversion, not administered directly
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate concentrations at each time point
|
||||||
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;
|
||||||
const allDoses: DoseWithTime[] = [];
|
|
||||||
|
|
||||||
const maxDayOffset = (parseInt(simulationDays, 10) || 3) - 1;
|
allDoses.forEach(dose => {
|
||||||
|
const timeSinceDoseHours = t - dose.timeMinutes / 60;
|
||||||
|
|
||||||
for (let day = -daysToSimulate; day <= maxDayOffset; day++) {
|
if (timeSinceDoseHours >= 0) {
|
||||||
const dayOffset = day * 24 * 60;
|
// Calculate LDX contribution
|
||||||
doseSchedule.forEach(d => {
|
const ldxConcentrations = calculateSingleDoseConcentration(
|
||||||
// Skip doses with empty or invalid time values
|
String(dose.ldx),
|
||||||
const timeStr = String(d.time || '').trim();
|
timeSinceDoseHours,
|
||||||
const doseStr = String(d.dose || '').trim();
|
pkParams
|
||||||
const doseNum = parseFloat(doseStr);
|
);
|
||||||
|
totalLdx += ldxConcentrations.ldx;
|
||||||
if (!timeStr || timeStr === '' || !doseStr || doseStr === '' || doseNum === 0 || isNaN(doseNum)) {
|
totalDamph += ldxConcentrations.damph;
|
||||||
return;
|
|
||||||
}
|
|
||||||
allDoses.push({ ...d, time: timeToMinutes(d.time) + dayOffset, isPlan: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentDeviations = [...deviationList];
|
|
||||||
if (correction) {
|
|
||||||
currentDeviations.push({ ...correction, isAdditional: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
currentDeviations.forEach(dev => {
|
|
||||||
// Skip deviations with empty or invalid time values
|
|
||||||
const timeStr = String(dev.time || '').trim();
|
|
||||||
const doseStr = String(dev.dose || '').trim();
|
|
||||||
const doseNum = parseFloat(doseStr);
|
|
||||||
|
|
||||||
if (!timeStr || timeStr === '' || !doseStr || doseStr === '' || doseNum === 0 || isNaN(doseNum)) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const devTime = timeToMinutes(dev.time) + (dev.dayOffset || 0) * 24 * 60;
|
|
||||||
if (!dev.isAdditional) {
|
|
||||||
const closestDoseIndex = allDoses.reduce((closest, dose, index) => {
|
|
||||||
if (!dose.isPlan) return closest;
|
|
||||||
const diff = Math.abs(dose.time - devTime);
|
|
||||||
if (diff <= 60 && diff < closest.minDiff) {
|
|
||||||
return { index, minDiff: diff };
|
|
||||||
}
|
|
||||||
return closest;
|
|
||||||
}, { index: -1, minDiff: 61 }).index;
|
|
||||||
if (closestDoseIndex !== -1) {
|
|
||||||
allDoses.splice(closestDoseIndex, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
allDoses.push({ ...dev, time: devTime });
|
|
||||||
});
|
|
||||||
|
|
||||||
allDoses.forEach(doseInfo => {
|
|
||||||
const timeSinceDoseHours = t - doseInfo.time / 60;
|
|
||||||
const concentrations = calculateSingleDoseConcentration(doseInfo.dose, timeSinceDoseHours, pkParams);
|
|
||||||
totalLdx += concentrations.ldx;
|
|
||||||
totalDamph += concentrations.damph;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
dataPoints.push({ timeHours: t, ldx: totalLdx, damph: totalDamph });
|
dataPoints.push({ timeHours: t, ldx: totalLdx, damph: totalDamph });
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
import { timeToMinutes } from './timeUtils';
|
import { timeToMinutes } from './timeUtils';
|
||||||
import { calculateCombinedProfile } from './calculations';
|
import { calculateCombinedProfile } from './calculations';
|
||||||
import type { Dose, Deviation, SteadyStateConfig, PkParams } from '../constants/defaults';
|
import type { DayGroup, SteadyStateConfig, PkParams } from '../constants/defaults';
|
||||||
|
|
||||||
interface SuggestionResult {
|
interface SuggestionResult {
|
||||||
text?: string;
|
text?: string;
|
||||||
@@ -28,83 +28,11 @@ interface Translations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const generateSuggestion = (
|
export const generateSuggestion = (
|
||||||
doses: Dose[],
|
days: DayGroup[],
|
||||||
deviations: Deviation[],
|
|
||||||
doseIncrement: string,
|
|
||||||
simulationDays: string,
|
|
||||||
steadyStateConfig: SteadyStateConfig,
|
steadyStateConfig: SteadyStateConfig,
|
||||||
pkParams: PkParams,
|
pkParams: PkParams
|
||||||
t: Translations
|
|
||||||
): SuggestionResult | null => {
|
): SuggestionResult | null => {
|
||||||
if (deviations.length === 0) {
|
// Suggestion feature is deprecated in day-based system
|
||||||
return null;
|
// This function is kept for backward compatibility but returns null
|
||||||
}
|
return null;
|
||||||
|
|
||||||
const lastDeviation = [...deviations].sort((a, b) =>
|
|
||||||
timeToMinutes(a.time) + (a.dayOffset || 0) * 1440 -
|
|
||||||
(timeToMinutes(b.time) + (b.dayOffset || 0) * 1440)
|
|
||||||
).pop();
|
|
||||||
|
|
||||||
if (!lastDeviation) return null;
|
|
||||||
|
|
||||||
const deviationTimeTotalMinutes = timeToMinutes(lastDeviation.time) + (lastDeviation.dayOffset || 0) * 1440;
|
|
||||||
|
|
||||||
type DoseWithOffset = Dose & { dayOffset: number };
|
|
||||||
let nextDose: DoseWithOffset | null = null;
|
|
||||||
let minDiff = Infinity;
|
|
||||||
|
|
||||||
doses.forEach(d => {
|
|
||||||
// Skip doses with empty or invalid time/dose values
|
|
||||||
const timeStr = String(d.time || '').trim();
|
|
||||||
const doseStr = String(d.dose || '').trim();
|
|
||||||
const doseNum = parseFloat(doseStr);
|
|
||||||
|
|
||||||
if (!timeStr || timeStr === '' || !doseStr || doseStr === '' || doseNum === 0 || isNaN(doseNum)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const doseTimeInMinutes = timeToMinutes(d.time);
|
|
||||||
for (let i = 0; i < (parseInt(simulationDays, 10) || 1); i++) {
|
|
||||||
const absoluteTime = doseTimeInMinutes + i * 1440;
|
|
||||||
const diff = absoluteTime - deviationTimeTotalMinutes;
|
|
||||||
if (diff > 0 && diff < minDiff) {
|
|
||||||
minDiff = diff;
|
|
||||||
nextDose = { ...d, dayOffset: i };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!nextDose) {
|
|
||||||
return { text: t.noSuitableNextDose };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type assertion after null check
|
|
||||||
const confirmedNextDose: DoseWithOffset = nextDose;
|
|
||||||
|
|
||||||
const numDoseIncrement = parseFloat(doseIncrement) || 1;
|
|
||||||
const idealProfile = calculateCombinedProfile(doses, [], null, steadyStateConfig, simulationDays, pkParams);
|
|
||||||
const deviatedProfile = calculateCombinedProfile(doses, deviations, null, steadyStateConfig, simulationDays, pkParams);
|
|
||||||
|
|
||||||
const nextDoseTimeHours = (timeToMinutes(confirmedNextDose.time) + (confirmedNextDose.dayOffset || 0) * 1440) / 60;
|
|
||||||
|
|
||||||
const idealConcentration = idealProfile.find(p => Math.abs(p.timeHours - nextDoseTimeHours) < 0.1)?.damph || 0;
|
|
||||||
const deviatedConcentration = deviatedProfile.find(p => Math.abs(p.timeHours - nextDoseTimeHours) < 0.1)?.damph || 0;
|
|
||||||
const concentrationDifference = idealConcentration - deviatedConcentration;
|
|
||||||
|
|
||||||
if (Math.abs(concentrationDifference) < 0.5) {
|
|
||||||
return { text: t.noSignificantCorrection };
|
|
||||||
}
|
|
||||||
|
|
||||||
const doseAdjustmentFactor = 0.5;
|
|
||||||
let doseChange = concentrationDifference / doseAdjustmentFactor;
|
|
||||||
doseChange = Math.round(doseChange / numDoseIncrement) * numDoseIncrement;
|
|
||||||
let suggestedDoseValue = (parseFloat(confirmedNextDose.dose) || 0) + doseChange;
|
|
||||||
suggestedDoseValue = Math.max(0, Math.min(70, suggestedDoseValue));
|
|
||||||
|
|
||||||
return {
|
|
||||||
time: confirmedNextDose.time,
|
|
||||||
dose: String(suggestedDoseValue),
|
|
||||||
isAdditional: false,
|
|
||||||
originalDose: confirmedNextDose.dose,
|
|
||||||
dayOffset: confirmedNextDose.dayOffset
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user