diff --git a/.gitignore b/.gitignore index f74e8b6..08124e2 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ yarn-error.log* /.env /public/static/ /hint-report/ + +~* +\#* +_* diff --git a/src/App.tsx b/src/App.tsx index b71b7b4..5d0f0ea 100644 --- a/src/App.tsx +++ b/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 (
@@ -105,9 +101,8 @@ const MedPlanAssistant = () => {
{ {/* Left Column - Controls */}
- updateState('doses', newDoses)} - t={t} - /> - - - -
diff --git a/src/components/day-schedule.tsx b/src/components/day-schedule.tsx new file mode 100644 index 0000000..9c5619f --- /dev/null +++ b/src/components/day-schedule.tsx @@ -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 = ({ + days, + doseIncrement, + onAddDay, + onRemoveDay, + onAddDose, + onRemoveDose, + onUpdateDose, + t +}) => { + const canAddDay = days.length < 3; + + return ( +
+ {days.map((day, dayIndex) => ( + + +
+
+ + {day.isTemplate ? t.regularPlan : t.dayNumber.replace('{{number}}', String(dayIndex + 1))} + + {day.isTemplate && ( + + {t.day} 1 + + )} +
+
+ {canAddDay && ( + + )} + {!day.isTemplate && ( + + )} +
+
+
+ + {/* Dose table header */} +
+
{t.time}
+
{t.ldx} (mg)
+
+
+ + {/* Dose rows */} + {day.doses.map((dose) => ( +
+ onUpdateDose(day.id, dose.id, 'time', value)} + required={true} + errorMessage={t.errorTimeRequired} + /> + onUpdateDose(day.id, dose.id, 'ldx', value)} + increment={doseIncrement} + min={0} + unit="mg" + required={true} + errorMessage={t.errorNumberRequired} + /> + +
+ ))} + + {/* Add dose button */} + {day.doses.length < 5 && ( + + )} +
+
+ ))} + + {/* Add day button */} + {canAddDay && ( + + )} +
+ ); +}; + +export default DaySchedule; diff --git a/src/components/settings.tsx b/src/components/settings.tsx index 80b21a9..6f5da92 100644 --- a/src/components/settings.tsx +++ b/src/components/settings.tsx @@ -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 ( @@ -46,17 +46,28 @@ const Settings = ({ /> +
+ + onUpdateUiSetting('showTemplateDay', checked)} + /> +
+
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} />
@@ -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} /> diff --git a/src/components/simulation-chart.tsx b/src/components/simulation-chart.tsx index 02f4331..a96f4b7 100644 --- a/src/components/simulation-chart.tsx +++ b/src/components/simulation-chart.tsx @@ -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(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 ( -
-
+
+ {/* Fixed Legend at top */} +
- - + + + {/* Invisible lines just to show in legend */} + {(chartView === 'damph' || chartView === 'both') && ( + + )} + {(chartView === 'ldx' || chartView === 'both') && ( + + )} + {templateProfile && (chartView === 'damph' || chartView === 'both') && ( + + )} + {templateProfile && (chartView === 'ldx' || chartView === 'both') && ( + + )} + + +
+ + {/* Chart */} +
+ {/* Scrollable chart area */} +
+
+ + { if (showDayTimeOnXAxis) { // Show 24h repeating format (0-23h) @@ -126,19 +208,25 @@ const SimulationChart = ({ xAxisId="hours" /> + yAxisId="concentration" + //label={{ value: t.concentration, angle: -90, position: 'insideLeft', offset: -10 }} + domain={chartDomain as any} + allowDecimals={false} + /> [`${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 }} - /> - + /> + + {(chartView === 'damph' || chartView === 'both') && ( )} {(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 && ( )} {(chartView === 'ldx' || chartView === 'both') && ( )} - {deviatedProfile && (chartView === 'damph' || chartView === 'both') && ( + {templateProfile && (chartView === 'damph' || chartView === 'both') && ( )} - {deviatedProfile && (chartView === 'ldx' || chartView === 'both') && ( + {templateProfile && (chartView === 'ldx' || chartView === 'both') && ( )} - - {correctedProfile && (chartView === 'damph' || chartView === 'both') && ( - - )} - {correctedProfile && (chartView === 'ldx' || chartView === 'both') && ( - - )} - - + + +
+
); -};export default SimulationChart; +}; + +export default SimulationChart; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..a448338 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/form-numeric-input.tsx b/src/components/ui/form-numeric-input.tsx index 64973c8..c1ac496 100644 --- a/src/components/ui/form-numeric-input.tsx +++ b/src/components/ui/form-numeric-input.tsx @@ -142,7 +142,7 @@ const FormNumericInput = React.forwardRef( 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( 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 ? ( + + {clearButton && allowEmpty && ( - ) : ( - )}
{unit && {unit}} diff --git a/src/components/ui/form-time-input.tsx b/src/components/ui/form-time-input.tsx index e561b10..1f448cf 100644 --- a/src/components/ui/form-time-input.tsx +++ b/src/components/ui/form-time-input.tsx @@ -18,13 +18,25 @@ import { cn } from "../../lib/utils" interface TimeInputProps extends Omit, 'onChange' | 'value'> { value: string onChange: (value: string) => void + unit?: string + align?: 'left' | 'center' | 'right' error?: boolean required?: boolean errorMessage?: string } const FormTimeInput = React.forwardRef( - ({ 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,78 +106,96 @@ const FormTimeInput = React.forwardRef( 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 (
- - - - - - -
-
-
Hour
-
- {Array.from({ length: 24 }, (_, i) => ( - - ))} +
+ + + + + + +
+
+
Hour
+
+ {Array.from({ length: 24 }, (_, i) => ( + + ))} +
+
+
+
Min
+
+ {Array.from({ length: 12 }, (_, i) => i * 5).map(minute => ( + + ))} +
-
-
Min
-
- {Array.from({ length: 12 }, (_, i) => i * 5).map(minute => ( - - ))} -
-
-
- - + + +
+ {unit && {unit}} {hasError && showError && (
{errorMessage} diff --git a/src/constants/defaults.ts b/src/constants/defaults.ts index 2a156be..ef9ee87 100644 --- a/src/constants/defaults.ts +++ b/src/constants/defaults.ts @@ -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' }, }, - 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' }, + days: [ + { + id: 'day-template', + isTemplate: true, + doses: [ + { 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', diff --git a/src/hooks/useAppState.ts b/src/hooks/useAppState.ts index f43bab7..bcc6506 100644 --- a/src/hooks/useAppState.ts +++ b/src/hooks/useAppState.ts @@ -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(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 }; - 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); + setAppState(prev => { + const newUiSettings = { ...prev.uiSettings, [key]: value }; + + // Auto-adjust displayedDays if simulationDays is reduced + if (key === 'simulationDays') { + 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) => { + 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 }; }; diff --git a/src/hooks/useSimulation.ts b/src/hooks/useSimulation.ts index 2a76b27..c04459c 100644 --- a/src/hooks/useSimulation.ts +++ b/src/hooks/useSimulation.ts @@ -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([]); - const [suggestion, setSuggestion] = React.useState(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]); - // Use templateDose if nextDose has no time - if (!nextDose.time || nextDose.time === '') { - nextDose = templateDose; + // 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]); - setDeviations([...deviations, { - time: nextDose.time, - dose: nextDose.dose, - label: nextDose.label || '', - isAdditional: false, - dayOffset: nextDayOffset - }]); - }; + // Calculate template continuation profile (day 2 onwards for comparison) + const templateProfile = React.useMemo(() => { + if (!showTemplateDay) return null; - const removeDeviation = (index: number) => { - setDeviations(deviations.filter((_, i) => i !== index)); - }; + const templateDay = days.find(day => day.isTemplate); + if (!templateDay) return null; - const handleDeviationChange = (index: number, field: keyof Deviation, value: any) => { - const newDeviations = [...deviations]; - (newDeviations[index] as any)[field] = value; - setDeviations(newDeviations); - }; + const numSimDays = parseInt(simulationDays, 10) || 3; + if (numSimDays < 2) return null; // Need at least 2 days to show continuation - const applySuggestion = () => { - if (!suggestion || !suggestion.dose) return; - setDeviations([...deviations, suggestion as Deviation]); - setSuggestion(null); - }; + // 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 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 }; }; diff --git a/src/locales/de.ts b/src/locales/de.ts index 323a754..ff893be 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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; diff --git a/src/locales/en.ts b/src/locales/en.ts index 1b9bc21..97c94c8 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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; diff --git a/src/utils/calculations.ts b/src/utils/calculations.ts index b9ab5dd..7756ed7 100644 --- a/src/utils/calculations.ts +++ b/src/utils/calculations.ts @@ -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 { - 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; - } - 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; + if (timeSinceDoseHours >= 0) { + // Calculate LDX contribution + const ldxConcentrations = calculateSingleDoseConcentration( + String(dose.ldx), + timeSinceDoseHours, + pkParams + ); + totalLdx += ldxConcentrations.ldx; + totalDamph += ldxConcentrations.damph; } - 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 }); diff --git a/src/utils/suggestions.ts b/src/utils/suggestions.ts index 27122bc..4ce5c08 100644 --- a/src/utils/suggestions.ts +++ b/src/utils/suggestions.ts @@ -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) { - 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 - }; + // Suggestion feature is deprecated in day-based system + // This function is kept for backward compatibility but returns null + return null; };