Update new unified dose management, style/other improvements

This commit is contained in:
2025-12-02 21:39:17 +00:00
parent 3fa5bf8360
commit bbdfc5f894
15 changed files with 830 additions and 495 deletions

4
.gitignore vendored
View File

@@ -35,3 +35,7 @@ yarn-error.log*
/.env /.env
/public/static/ /public/static/
/hint-report/ /hint-report/
~*
\#*
_*

View File

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

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

View File

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

View File

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

View 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 }

View File

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

View File

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

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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