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
|
||||
/public/static/
|
||||
/hint-report/
|
||||
|
||||
~*
|
||||
\#*
|
||||
_*
|
||||
|
||||
59
src/App.tsx
59
src/App.tsx
@@ -12,9 +12,7 @@
|
||||
import React from 'react';
|
||||
|
||||
// Components
|
||||
import DoseSchedule from './components/dose-schedule';
|
||||
import DeviationList from './components/deviation-list';
|
||||
import SuggestionPanel from './components/suggestion-panel';
|
||||
import DaySchedule from './components/day-schedule';
|
||||
import SimulationChart from './components/simulation-chart';
|
||||
import Settings from './components/settings';
|
||||
import LanguageSelector from './components/language-selector';
|
||||
@@ -31,15 +29,19 @@ const MedPlanAssistant = () => {
|
||||
|
||||
const {
|
||||
appState,
|
||||
updateState,
|
||||
updateNestedState,
|
||||
updateUiSetting,
|
||||
handleReset
|
||||
handleReset,
|
||||
addDay,
|
||||
removeDay,
|
||||
addDoseToDay,
|
||||
removeDoseFromDay,
|
||||
updateDoseInDay
|
||||
} = useAppState();
|
||||
|
||||
const {
|
||||
pkParams,
|
||||
doses,
|
||||
days,
|
||||
therapeuticRange,
|
||||
doseIncrement,
|
||||
uiSettings
|
||||
@@ -50,21 +52,15 @@ const MedPlanAssistant = () => {
|
||||
chartView,
|
||||
yAxisMin,
|
||||
yAxisMax,
|
||||
showTemplateDay,
|
||||
simulationDays,
|
||||
displayedDays
|
||||
} = uiSettings;
|
||||
|
||||
const {
|
||||
deviations,
|
||||
suggestion,
|
||||
idealProfile,
|
||||
deviatedProfile,
|
||||
correctedProfile,
|
||||
addDeviation,
|
||||
removeDeviation,
|
||||
handleDeviationChange,
|
||||
applySuggestion
|
||||
} = useSimulation(appState, t);
|
||||
combinedProfile,
|
||||
templateProfile
|
||||
} = useSimulation(appState);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-4 sm:p-6 lg:p-8">
|
||||
@@ -105,9 +101,8 @@ const MedPlanAssistant = () => {
|
||||
</div>
|
||||
|
||||
<SimulationChart
|
||||
idealProfile={idealProfile}
|
||||
deviatedProfile={deviatedProfile}
|
||||
correctedProfile={correctedProfile}
|
||||
combinedProfile={combinedProfile}
|
||||
templateProfile={showTemplateDay ? templateProfile : null}
|
||||
chartView={chartView}
|
||||
showDayTimeOnXAxis={showDayTimeOnXAxis}
|
||||
therapeuticRange={therapeuticRange}
|
||||
@@ -121,26 +116,14 @@ const MedPlanAssistant = () => {
|
||||
|
||||
{/* Left Column - Controls */}
|
||||
<div className="xl:col-span-1 space-y-6">
|
||||
<DoseSchedule
|
||||
doses={doses}
|
||||
<DaySchedule
|
||||
days={days}
|
||||
doseIncrement={doseIncrement}
|
||||
onUpdateDoses={(newDoses: any) => updateState('doses', newDoses)}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<DeviationList
|
||||
deviations={deviations}
|
||||
doseIncrement={doseIncrement}
|
||||
simulationDays={simulationDays}
|
||||
onAddDeviation={addDeviation}
|
||||
onRemoveDeviation={removeDeviation}
|
||||
onDeviationChange={handleDeviationChange}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<SuggestionPanel
|
||||
suggestion={suggestion}
|
||||
onApplySuggestion={applySuggestion}
|
||||
onAddDay={addDay}
|
||||
onRemoveDay={removeDay}
|
||||
onAddDose={addDoseToDay}
|
||||
onRemoveDose={removeDoseFromDay}
|
||||
onUpdateDose={updateDoseInDay}
|
||||
t={t}
|
||||
/>
|
||||
</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,
|
||||
t
|
||||
}: any) => {
|
||||
const { showDayTimeOnXAxis, yAxisMin, yAxisMax, simulationDays, displayedDays } = uiSettings;
|
||||
const { showDayTimeOnXAxis, yAxisMin, yAxisMax, showTemplateDay, simulationDays, displayedDays } = uiSettings;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -46,17 +46,28 @@ const Settings = ({
|
||||
/>
|
||||
</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">
|
||||
<Label className="font-medium">{t.simulationDuration}</Label>
|
||||
<FormNumericInput
|
||||
value={simulationDays}
|
||||
onChange={val => onUpdateUiSetting('simulationDays', val)}
|
||||
increment={1}
|
||||
min={2}
|
||||
min={3}
|
||||
max={7}
|
||||
unit={t.days}
|
||||
required={true}
|
||||
errorMessage={t.simulationDaysRequired || 'Simulation days is required'}
|
||||
errorMessage={t.errorNumberRequired}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -67,10 +78,10 @@ const Settings = ({
|
||||
onChange={val => onUpdateUiSetting('displayedDays', val)}
|
||||
increment={1}
|
||||
min={1}
|
||||
max={parseInt(simulationDays, 10) || 1}
|
||||
max={parseInt(simulationDays, 10) || 3}
|
||||
unit={t.days}
|
||||
required={true}
|
||||
errorMessage={t.displayedDaysRequired || 'Displayed days is required'}
|
||||
errorMessage={t.errorNumberRequired}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -34,9 +34,8 @@ const CHART_COLORS = {
|
||||
} as const;
|
||||
|
||||
const SimulationChart = ({
|
||||
idealProfile,
|
||||
deviatedProfile,
|
||||
correctedProfile,
|
||||
combinedProfile,
|
||||
templateProfile,
|
||||
chartView,
|
||||
showDayTimeOnXAxis,
|
||||
therapeuticRange,
|
||||
@@ -57,8 +56,6 @@ const SimulationChart = ({
|
||||
return ticks;
|
||||
}, [totalHours]);
|
||||
|
||||
const chartWidthPercentage = Math.max(100, (totalHours / ( (parseInt(displayedDays, 10) || 2) * 25)) * 100);
|
||||
|
||||
const chartDomain = React.useMemo(() => {
|
||||
const numMin = parseFloat(yAxisMin);
|
||||
const numMax = parseFloat(yAxisMax);
|
||||
@@ -71,49 +68,134 @@ const SimulationChart = ({
|
||||
const mergedData = React.useMemo(() => {
|
||||
const dataMap = new Map();
|
||||
|
||||
// Add ideal profile data
|
||||
idealProfile?.forEach((point: any) => {
|
||||
// Add combined profile data (actual plan with all days)
|
||||
combinedProfile?.forEach((point: any) => {
|
||||
dataMap.set(point.timeHours, {
|
||||
timeHours: point.timeHours,
|
||||
idealDamph: point.damph,
|
||||
idealLdx: point.ldx
|
||||
combinedDamph: point.damph,
|
||||
combinedLdx: point.ldx
|
||||
});
|
||||
});
|
||||
|
||||
// Add deviated profile data
|
||||
deviatedProfile?.forEach((point: any) => {
|
||||
// Add template profile data (regular plan only) if provided
|
||||
templateProfile?.forEach((point: any) => {
|
||||
const existing = dataMap.get(point.timeHours) || { timeHours: point.timeHours };
|
||||
dataMap.set(point.timeHours, {
|
||||
...existing,
|
||||
deviatedDamph: point.damph,
|
||||
deviatedLdx: 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
|
||||
templateDamph: point.damph,
|
||||
templateLdx: point.ldx
|
||||
});
|
||||
});
|
||||
|
||||
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 (
|
||||
<div className="flex-grow w-full overflow-x-auto overflow-y-hidden">
|
||||
<div style={{ width: `${chartWidthPercentage}%`, height: '100%', minWidth: '100%' }}>
|
||||
<div ref={containerRef} className="flex-grow w-full flex flex-col overflow-y-hidden">
|
||||
{/* Fixed Legend at top */}
|
||||
<div style={{ height: 40, marginBottom: 8, paddingLeft: yAxisWidth + 10 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={mergedData} margin={{ top: 20, right: 20, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<LineChart data={mergedData} margin={{ top: 0, right: 20, left: 0, bottom: 0 }}>
|
||||
<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
|
||||
dataKey="timeHours"
|
||||
type="number"
|
||||
domain={[0, totalHours]}
|
||||
ticks={chartTicks}
|
||||
tickCount={chartTicks.length}
|
||||
interval={0}
|
||||
tickFormatter={(h) => {
|
||||
if (showDayTimeOnXAxis) {
|
||||
// Show 24h repeating format (0-23h)
|
||||
@@ -126,19 +208,25 @@ const SimulationChart = ({
|
||||
xAxisId="hours"
|
||||
/>
|
||||
<YAxis
|
||||
label={{ value: t.concentration, angle: -90, position: 'insideLeft', offset: -10 }}
|
||||
yAxisId="concentration"
|
||||
//label={{ value: t.concentration, angle: -90, position: 'insideLeft', offset: -10 }}
|
||||
domain={chartDomain as any}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
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 }}
|
||||
allowEscapeViewBox={{ x: false, y: false }}
|
||||
cursor={{ stroke: CHART_COLORS.cursor, strokeWidth: 1, strokeDasharray: '1 1' }}
|
||||
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') && (
|
||||
<ReferenceLine
|
||||
@@ -147,6 +235,7 @@ const SimulationChart = ({
|
||||
stroke={CHART_COLORS.therapeuticMin}
|
||||
strokeDasharray="3 3"
|
||||
xAxisId="hours"
|
||||
yAxisId="concentration"
|
||||
/>
|
||||
)}
|
||||
{(chartView === 'damph' || chartView === 'both') && (
|
||||
@@ -156,10 +245,11 @@ const SimulationChart = ({
|
||||
stroke={CHART_COLORS.therapeuticMax}
|
||||
strokeDasharray="3 3"
|
||||
xAxisId="hours"
|
||||
yAxisId="concentration"
|
||||
/>
|
||||
)}
|
||||
|
||||
{[...Array(parseInt(simulationDays, 10) || 0).keys()].map(day => (
|
||||
{[...Array(parseInt(simulationDays, 10) || 3).keys()].map(day => (
|
||||
day > 0 && (
|
||||
<ReferenceLine
|
||||
key={day}
|
||||
@@ -174,85 +264,68 @@ const SimulationChart = ({
|
||||
{(chartView === 'damph' || chartView === 'both') && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="idealDamph"
|
||||
name={`${t.dAmphetamine} (Ideal)`}
|
||||
dataKey="combinedDamph"
|
||||
name={`${t.dAmphetamine}`}
|
||||
stroke={CHART_COLORS.idealDamph}
|
||||
strokeWidth={2.5}
|
||||
dot={false}
|
||||
xAxisId="hours"
|
||||
yAxisId="concentration"
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
{(chartView === 'ldx' || chartView === 'both') && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="idealLdx"
|
||||
name={`${t.lisdexamfetamine} (Ideal)`}
|
||||
dataKey="combinedLdx"
|
||||
name={`${t.lisdexamfetamine}`}
|
||||
stroke={CHART_COLORS.idealLdx}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
strokeDasharray="3 3"
|
||||
xAxisId="hours"
|
||||
yAxisId="concentration"
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
|
||||
{deviatedProfile && (chartView === 'damph' || chartView === 'both') && (
|
||||
{templateProfile && (chartView === 'damph' || chartView === 'both') && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="deviatedDamph"
|
||||
name={`${t.dAmphetamine} (Deviation)`}
|
||||
stroke={CHART_COLORS.deviatedDamph}
|
||||
dataKey="templateDamph"
|
||||
name={`${t.dAmphetamine} (${t.regularPlan} ${t.continuation || 'continuation'})`}
|
||||
stroke={CHART_COLORS.idealDamph}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
strokeDasharray="3 3"
|
||||
dot={false}
|
||||
xAxisId="hours"
|
||||
yAxisId="concentration"
|
||||
connectNulls
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
)}
|
||||
{deviatedProfile && (chartView === 'ldx' || chartView === 'both') && (
|
||||
{templateProfile && (chartView === 'ldx' || chartView === 'both') && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="deviatedLdx"
|
||||
name={`${t.lisdexamfetamine} (Deviation)`}
|
||||
stroke={CHART_COLORS.deviatedLdx}
|
||||
dataKey="templateLdx"
|
||||
name={`${t.lisdexamfetamine} (${t.regularPlan} ${t.continuation || 'continuation'})`}
|
||||
stroke={CHART_COLORS.idealLdx}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
xAxisId="hours"
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
|
||||
{correctedProfile && (chartView === 'damph' || chartView === 'both') && (
|
||||
<Line
|
||||
type="monotone"
|
||||
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"
|
||||
strokeDasharray="3 3"
|
||||
dot={false}
|
||||
xAxisId="hours"
|
||||
yAxisId="concentration"
|
||||
connectNulls
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
)}
|
||||
</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"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-9 w-9 rounded-r-none",
|
||||
"h-9 w-9 rounded-r-none border-r-0",
|
||||
hasError && "border-destructive"
|
||||
)}
|
||||
onClick={() => updateValue(-1)}
|
||||
@@ -159,14 +159,28 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"w-24",
|
||||
"rounded-none border-x-0 h-9",
|
||||
"w-20 h-9 z-20",
|
||||
"rounded-none",
|
||||
getAlignmentClass(),
|
||||
hasError && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
{...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
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -180,20 +194,6 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
||||
>
|
||||
<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>}
|
||||
|
||||
@@ -18,13 +18,25 @@ import { cn } from "../../lib/utils"
|
||||
interface TimeInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
unit?: string
|
||||
align?: 'left' | 'center' | 'right'
|
||||
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) => {
|
||||
({
|
||||
value,
|
||||
onChange,
|
||||
unit,
|
||||
align = 'center',
|
||||
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)
|
||||
@@ -94,8 +106,18 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
|
||||
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 (
|
||||
<div ref={containerRef} className={cn("relative flex items-center gap-2", className)}>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
ref={ref}
|
||||
type="text"
|
||||
@@ -105,7 +127,9 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
|
||||
onFocus={handleFocus}
|
||||
placeholder="HH:MM"
|
||||
className={cn(
|
||||
"w-24",
|
||||
"w-20 h-9 z-20",
|
||||
"rounded-r-none",
|
||||
getAlignmentClass(),
|
||||
hasError && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
{...props}
|
||||
@@ -116,7 +140,11 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(hasError && "border-destructive")}
|
||||
className={cn(
|
||||
"h-9 w-9",
|
||||
"rounded-l-none border-l-0",
|
||||
hasError && "border-destructive")}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -166,6 +194,8 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</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-48 bg-destructive text-destructive-foreground text-xs p-2 rounded-md shadow-lg">
|
||||
{errorMessage}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @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;
|
||||
|
||||
// Type definitions
|
||||
@@ -17,15 +17,17 @@ export interface PkParams {
|
||||
ldx: { halfLife: string; absorptionRate: string };
|
||||
}
|
||||
|
||||
export interface Dose {
|
||||
export interface DayDose {
|
||||
id: string;
|
||||
time: string;
|
||||
dose: string;
|
||||
label: string;
|
||||
ldx: string;
|
||||
damph?: string; // Optional, kept for backwards compatibility but not used in UI
|
||||
}
|
||||
|
||||
export interface Deviation extends Dose {
|
||||
dayOffset?: number;
|
||||
isAdditional: boolean;
|
||||
export interface DayGroup {
|
||||
id: string;
|
||||
isTemplate: boolean;
|
||||
doses: DayDose[];
|
||||
}
|
||||
|
||||
export interface SteadyStateConfig {
|
||||
@@ -39,7 +41,8 @@ export interface TherapeuticRange {
|
||||
|
||||
export interface UiSettings {
|
||||
showDayTimeOnXAxis: boolean;
|
||||
chartView: 'damph' | 'ldx' | 'both';
|
||||
showTemplateDay: boolean;
|
||||
chartView: 'ldx' | 'damph' | 'both';
|
||||
yAxisMin: string;
|
||||
yAxisMax: string;
|
||||
simulationDays: string;
|
||||
@@ -48,13 +51,25 @@ export interface UiSettings {
|
||||
|
||||
export interface AppState {
|
||||
pkParams: PkParams;
|
||||
doses: Dose[];
|
||||
days: DayGroup[];
|
||||
steadyStateConfig: SteadyStateConfig;
|
||||
therapeuticRange: TherapeuticRange;
|
||||
doseIncrement: string;
|
||||
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 {
|
||||
timeHours: number;
|
||||
ldx: number;
|
||||
@@ -67,18 +82,24 @@ export const getDefaultState = (): AppState => ({
|
||||
damph: { halfLife: '11' },
|
||||
ldx: { halfLife: '0.8', absorptionRate: '1.5' },
|
||||
},
|
||||
days: [
|
||||
{
|
||||
id: 'day-template',
|
||||
isTemplate: true,
|
||||
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' },
|
||||
{ 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' },
|
||||
therapeuticRange: { min: '10.5', max: '11.5' },
|
||||
doseIncrement: '2.5',
|
||||
uiSettings: {
|
||||
showDayTimeOnXAxis: true,
|
||||
showTemplateDay: false,
|
||||
chartView: 'both',
|
||||
yAxisMin: '0',
|
||||
yAxisMax: '16',
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
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 = () => {
|
||||
const [appState, setAppState] = React.useState<AppState>(getDefaultState);
|
||||
@@ -26,6 +26,7 @@ export const useAppState = () => {
|
||||
...defaults,
|
||||
...parsedState,
|
||||
pkParams: {...defaults.pkParams, ...parsedState.pkParams},
|
||||
days: parsedState.days || defaults.days,
|
||||
uiSettings: {...defaults.uiSettings, ...parsedState.uiSettings},
|
||||
});
|
||||
}
|
||||
@@ -40,7 +41,7 @@ export const useAppState = () => {
|
||||
try {
|
||||
const stateToSave = {
|
||||
pkParams: appState.pkParams,
|
||||
doses: appState.doses,
|
||||
days: appState.days,
|
||||
steadyStateConfig: appState.steadyStateConfig,
|
||||
therapeuticRange: appState.therapeuticRange,
|
||||
doseIncrement: appState.doseIncrement,
|
||||
@@ -72,15 +73,119 @@ export const useAppState = () => {
|
||||
key: K,
|
||||
value: AppState['uiSettings'][K]
|
||||
) => {
|
||||
const newUiSettings = { ...appState.uiSettings, [key]: value };
|
||||
setAppState(prev => {
|
||||
const newUiSettings = { ...prev.uiSettings, [key]: value };
|
||||
|
||||
// Auto-adjust displayedDays if simulationDays is reduced
|
||||
if (key === 'simulationDays') {
|
||||
const simDaysNum = parseInt(value as string, 10) || 1;
|
||||
const dispDaysNum = parseInt(newUiSettings.displayedDays, 10) || 1;
|
||||
if (dispDaysNum > simDaysNum) {
|
||||
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 = () => {
|
||||
@@ -96,6 +201,12 @@ export const useAppState = () => {
|
||||
updateState,
|
||||
updateNestedState,
|
||||
updateUiSetting,
|
||||
addDay,
|
||||
removeDay,
|
||||
updateDay,
|
||||
addDoseToDay,
|
||||
removeDoseFromDay,
|
||||
updateDoseInDay,
|
||||
handleReset
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* Simulation Hook
|
||||
*
|
||||
* Manages pharmacokinetic simulation calculations and deviation handling.
|
||||
* Computes ideal, deviated, and corrected concentration profiles.
|
||||
* Generates dose correction suggestions based on deviations.
|
||||
* Manages pharmacokinetic simulation calculations for day-based plans.
|
||||
* Computes concentration profiles from all days in the schedule.
|
||||
*
|
||||
* @author Andreas Weyer
|
||||
* @license MIT
|
||||
@@ -11,134 +10,81 @@
|
||||
|
||||
import React from 'react';
|
||||
import { calculateCombinedProfile } from '../utils/calculations';
|
||||
import { generateSuggestion } from '../utils/suggestions';
|
||||
import { timeToMinutes } from '../utils/timeUtils';
|
||||
import type { AppState, Deviation } from '../constants/defaults';
|
||||
import type { AppState } from '../constants/defaults';
|
||||
|
||||
interface SuggestionResult {
|
||||
text?: string;
|
||||
time?: string;
|
||||
dose?: string;
|
||||
isAdditional?: boolean;
|
||||
originalDose?: string;
|
||||
dayOffset?: number;
|
||||
}
|
||||
export const useSimulation = (appState: AppState) => {
|
||||
const { pkParams, days, steadyStateConfig, uiSettings } = appState;
|
||||
const { showTemplateDay, simulationDays } = uiSettings;
|
||||
|
||||
interface Translations {
|
||||
noSuitableNextDose: string;
|
||||
noSignificantCorrection: string;
|
||||
}
|
||||
// Extend days to match simulation duration
|
||||
const extendedDays = React.useMemo(() => {
|
||||
const numSimDays = parseInt(simulationDays, 10) || 3;
|
||||
if (days.length >= numSimDays) return days;
|
||||
|
||||
export const useSimulation = (appState: AppState, t: Translations) => {
|
||||
const { pkParams, doses, steadyStateConfig, doseIncrement, uiSettings } = appState;
|
||||
const { simulationDays } = uiSettings;
|
||||
// Repeat template day to fill simulation period
|
||||
const templateDay = days.find(d => d.isTemplate);
|
||||
if (!templateDay) return days;
|
||||
|
||||
const [deviations, setDeviations] = React.useState<Deviation[]>([]);
|
||||
const [suggestion, setSuggestion] = React.useState<SuggestionResult | null>(null);
|
||||
|
||||
const calculateCombinedProfileMemo = React.useCallback(
|
||||
(doseSchedule = doses, deviationList: Deviation[] = [], correction: Deviation | null = null) =>
|
||||
calculateCombinedProfile(
|
||||
doseSchedule,
|
||||
deviationList,
|
||||
correction,
|
||||
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;
|
||||
const extended = [...days];
|
||||
for (let i = days.length; i < numSimDays; i++) {
|
||||
extended.push({
|
||||
id: `extended-day-${i}`,
|
||||
isTemplate: false,
|
||||
doses: templateDay.doses.map(d => ({
|
||||
id: `${d.id}-ext-${i}`,
|
||||
time: d.time,
|
||||
ldx: d.ldx
|
||||
}))
|
||||
});
|
||||
}
|
||||
return extended;
|
||||
}, [days, simulationDays]);
|
||||
|
||||
// Calculate profile with extended days
|
||||
const combinedProfile = React.useMemo(() => {
|
||||
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]);
|
||||
|
||||
// Use templateDose if nextDose has no time
|
||||
if (!nextDose.time || nextDose.time === '') {
|
||||
nextDose = templateDose;
|
||||
}
|
||||
// Calculate template continuation profile (day 2 onwards for comparison)
|
||||
const templateProfile = React.useMemo(() => {
|
||||
if (!showTemplateDay) return null;
|
||||
|
||||
setDeviations([...deviations, {
|
||||
time: nextDose.time,
|
||||
dose: nextDose.dose,
|
||||
label: nextDose.label || '',
|
||||
isAdditional: false,
|
||||
dayOffset: nextDayOffset
|
||||
}]);
|
||||
};
|
||||
const templateDay = days.find(day => day.isTemplate);
|
||||
if (!templateDay) return null;
|
||||
|
||||
const removeDeviation = (index: number) => {
|
||||
setDeviations(deviations.filter((_, i) => i !== index));
|
||||
};
|
||||
const numSimDays = parseInt(simulationDays, 10) || 3;
|
||||
if (numSimDays < 2) return null; // Need at least 2 days to show continuation
|
||||
|
||||
const handleDeviationChange = (index: number, field: keyof Deviation, value: any) => {
|
||||
const newDeviations = [...deviations];
|
||||
(newDeviations[index] as any)[field] = value;
|
||||
setDeviations(newDeviations);
|
||||
};
|
||||
// Create array with template day repeated for entire simulation period
|
||||
const templateDays = Array.from({ length: numSimDays }, (_, i) => ({
|
||||
id: `template-continuation-${i}`,
|
||||
isTemplate: false,
|
||||
doses: templateDay.doses.map(d => ({
|
||||
id: `${d.id}-template-${i}`,
|
||||
time: d.time,
|
||||
ldx: d.ldx
|
||||
}))
|
||||
}));
|
||||
|
||||
const applySuggestion = () => {
|
||||
if (!suggestion || !suggestion.dose) return;
|
||||
setDeviations([...deviations, suggestion as Deviation]);
|
||||
setSuggestion(null);
|
||||
};
|
||||
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 {
|
||||
deviations,
|
||||
suggestion,
|
||||
idealProfile,
|
||||
deviatedProfile,
|
||||
correctedProfile,
|
||||
addDeviation,
|
||||
removeDeviation,
|
||||
handleDeviationChange,
|
||||
applySuggestion
|
||||
combinedProfile,
|
||||
templateProfile,
|
||||
visibleDays
|
||||
};
|
||||
};
|
||||
|
||||
@@ -77,7 +77,28 @@ export const de = {
|
||||
|
||||
// Field validation
|
||||
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;
|
||||
|
||||
@@ -77,7 +77,28 @@ export const en = {
|
||||
|
||||
// Field validation
|
||||
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;
|
||||
|
||||
@@ -11,84 +11,80 @@
|
||||
|
||||
import { timeToMinutes } from './timeUtils';
|
||||
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'> {
|
||||
time: number;
|
||||
isPlan?: boolean;
|
||||
interface ProcessedDose {
|
||||
timeMinutes: number;
|
||||
ldx: number;
|
||||
damph: number;
|
||||
}
|
||||
|
||||
export const calculateCombinedProfile = (
|
||||
doseSchedule: Dose[],
|
||||
deviationList: Deviation[] = [],
|
||||
correction: Deviation | null = null,
|
||||
days: DayGroup[],
|
||||
steadyStateConfig: SteadyStateConfig,
|
||||
simulationDays: string,
|
||||
pkParams: PkParams
|
||||
): ConcentrationPoint[] => {
|
||||
const dataPoints: ConcentrationPoint[] = [];
|
||||
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);
|
||||
|
||||
// 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) {
|
||||
let totalLdx = 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++) {
|
||||
const dayOffset = day * 24 * 60;
|
||||
doseSchedule.forEach(d => {
|
||||
// Skip doses with empty or invalid time values
|
||||
const timeStr = String(d.time || '').trim();
|
||||
const doseStr = String(d.dose || '').trim();
|
||||
const doseNum = parseFloat(doseStr);
|
||||
|
||||
if (!timeStr || timeStr === '' || !doseStr || doseStr === '' || doseNum === 0 || isNaN(doseNum)) {
|
||||
return;
|
||||
if (timeSinceDoseHours >= 0) {
|
||||
// Calculate LDX contribution
|
||||
const ldxConcentrations = calculateSingleDoseConcentration(
|
||||
String(dose.ldx),
|
||||
timeSinceDoseHours,
|
||||
pkParams
|
||||
);
|
||||
totalLdx += ldxConcentrations.ldx;
|
||||
totalDamph += ldxConcentrations.damph;
|
||||
}
|
||||
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 });
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { timeToMinutes } from './timeUtils';
|
||||
import { calculateCombinedProfile } from './calculations';
|
||||
import type { Dose, Deviation, SteadyStateConfig, PkParams } from '../constants/defaults';
|
||||
import type { DayGroup, SteadyStateConfig, PkParams } from '../constants/defaults';
|
||||
|
||||
interface SuggestionResult {
|
||||
text?: string;
|
||||
@@ -28,83 +28,11 @@ interface Translations {
|
||||
}
|
||||
|
||||
export const generateSuggestion = (
|
||||
doses: Dose[],
|
||||
deviations: Deviation[],
|
||||
doseIncrement: string,
|
||||
simulationDays: string,
|
||||
days: DayGroup[],
|
||||
steadyStateConfig: SteadyStateConfig,
|
||||
pkParams: PkParams,
|
||||
t: Translations
|
||||
pkParams: PkParams
|
||||
): SuggestionResult | null => {
|
||||
if (deviations.length === 0) {
|
||||
// Suggestion feature is deprecated in day-based system
|
||||
// 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