diff --git a/src/App.tsx b/src/App.tsx index a5c7e41..62c4cd4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -130,6 +130,7 @@ const MedPlanAssistant = () => { displayedDays, showDayReferenceLines } = uiSettings; + const showIntakeTimeLines = (uiSettings as any).showIntakeTimeLines ?? false; const { combinedProfile, @@ -161,7 +162,7 @@ const MedPlanAssistant = () => { return ( -
+
{/* sm:p-6 lg:p-8 */} {/* Disclaimer Modal */} { />
@@ -211,10 +212,10 @@ const MedPlanAssistant = () => {

{t('appSubtitle')}

-
+
{/* Both Columns - Chart */} -
@@ -274,6 +275,7 @@ const MedPlanAssistant = () => { chartView={chartView} showDayTimeOnXAxis={showDayTimeOnXAxis} showDayReferenceLines={showDayReferenceLines} + showIntakeTimeLines={showIntakeTimeLines} showTherapeuticRange={uiSettings.showTherapeuticRange ?? true} therapeuticRange={therapeuticRange} simulationDays={simulationDays} @@ -286,7 +288,7 @@ const MedPlanAssistant = () => {
{/* Left Column - Controls */} -
+
{
{/* Right Column - Settings */} -
+
= ({ // Track collapsed state for each day (by day ID) const [collapsedDays, setCollapsedDays] = React.useState>(new Set()); + // Track pending sort timeouts for debounced sorting + const [pendingSorts, setPendingSorts] = React.useState>(new Map()); + + // Schedule a debounced sort for a day + const scheduleSort = React.useCallback((dayId: string) => { + // Cancel any existing pending sort for this day + const existingTimeout = pendingSorts.get(dayId); + if (existingTimeout) { + clearTimeout(existingTimeout); + } + + // Schedule new sort after delay + const timeoutId = setTimeout(() => { + onSortDoses(dayId); + setPendingSorts(prev => { + const newMap = new Map(prev); + newMap.delete(dayId); + return newMap; + }); + }, 100); + + setPendingSorts(prev => { + const newMap = new Map(prev); + newMap.set(dayId, timeoutId); + return newMap; + }); + }, [pendingSorts, onSortDoses]); + + // Handle time field blur - schedule a sort + const handleTimeBlur = React.useCallback((dayId: string) => { + scheduleSort(dayId); + }, [scheduleSort]); + + // Wrap action handlers to cancel pending sorts and execute action, then sort + const handleActionWithSort = React.useCallback((dayId: string, action: () => void) => { + // Cancel pending sort + const pendingTimeout = pendingSorts.get(dayId); + if (pendingTimeout) { + clearTimeout(pendingTimeout); + setPendingSorts(prev => { + const newMap = new Map(prev); + newMap.delete(dayId); + return newMap; + }); + } + + // Execute the action + action(); + + // Schedule sort after action completes + setTimeout(() => { + onSortDoses(dayId); + }, 50); + }, [pendingSorts, onSortDoses]); + + // Clean up pending timeouts on unmount + React.useEffect(() => { + return () => { + pendingSorts.forEach(timeout => clearTimeout(timeout)); + }; + }, [pendingSorts]); + + // Calculate time delta from previous intake (across all days) + const calculateTimeDelta = (dayIndex: number, doseIndex: number): string => { + if (dayIndex === 0 && doseIndex === 0) { + return '+0:00'; // First dose of all days + } + + const currentDay = days[dayIndex]; + const currentDose = currentDay.doses[doseIndex]; + + if (!currentDose.time) return ''; + + const [currHours, currMinutes] = currentDose.time.split(':').map(Number); + const currentTotalMinutes = (dayIndex * 24 * 60) + (currHours * 60) + currMinutes; + + let prevTotalMinutes = 0; + + // Find previous dose + if (doseIndex > 0) { + // Previous dose is in the same day + const prevDose = currentDay.doses[doseIndex - 1]; + if (prevDose.time) { + const [prevHours, prevMinutes] = prevDose.time.split(':').map(Number); + prevTotalMinutes = (dayIndex * 24 * 60) + (prevHours * 60) + prevMinutes; + } + } else if (dayIndex > 0) { + // Previous dose is the last dose of the previous day + const prevDay = days[dayIndex - 1]; + if (prevDay.doses.length > 0) { + const lastDoseOfPrevDay = prevDay.doses[prevDay.doses.length - 1]; + if (lastDoseOfPrevDay.time) { + const [prevHours, prevMinutes] = lastDoseOfPrevDay.time.split(':').map(Number); + prevTotalMinutes = ((dayIndex - 1) * 24 * 60) + (prevHours * 60) + prevMinutes; + } + } + } + + const deltaMinutes = currentTotalMinutes - prevTotalMinutes; + const deltaHours = Math.floor(deltaMinutes / 60); + const remainingMinutes = deltaMinutes % 60; + + return `+${deltaHours}:${remainingMinutes.toString().padStart(2, '0')}`; + }; + + // Calculate dose index across all days + const getDoseGlobalIndex = (dayIndex: number, doseIndex: number): number => { + let globalIndex = 1; + + for (let d = 0; d < dayIndex; d++) { + globalIndex += days[d].doses.length; + } + + globalIndex += doseIndex + 1; + return globalIndex; + }; + // Load and persist collapsed days state React.useEffect(() => { const savedCollapsed = localStorage.getItem('dayScheduleCollapsedDays_v1'); @@ -81,17 +199,6 @@ const DaySchedule: React.FC = ({ }); }; - // Check if doses are sorted chronologically - const isDaySorted = (day: DayGroup): boolean => { - for (let i = 1; i < day.doses.length; i++) { - const prevTime = day.doses[i - 1].time || '00:00'; - const currTime = day.doses[i].time || '00:00'; - if (prevTime > currTime) { - return false; - } - } - return true; - }; return (
@@ -116,257 +223,258 @@ const DaySchedule: React.FC = ({ totalMgDiff = dayTotal - templateTotal; } + // FIXME incomplete implementation of @container and @min-[497px]: + // the intention is to wrap dose buttons as well as header badges all at the same time + // at a specific container width while adding a spacer to align buttons with time field return ( - - toggleDayCollapse(day.id)} - toggleLabel={collapsedDays.has(day.id) ? t('expandDay') : t('collapseDay')} - rightSection={ - <> - {canAddDay && ( - onAddDay(day.id)} - icon={} - tooltip={t('cloneDay')} - size="sm" - variant="outline" - /> - )} - {!day.isTemplate && ( - onRemoveDay(day.id)} - icon={} - tooltip={t('removeDay')} - size="sm" - variant="outline" - className="text-destructive hover:bg-destructive hover:text-destructive-foreground" - /> - )} - - } - > - - {t('day')} {dayIndex + 1} - - {!day.isTemplate && doseCountDiff !== 0 ? ( - - - - - -

- {doseCountDiff > 0 ? '+' : ''}{doseCountDiff} {Math.abs(doseCountDiff) === 1 ? t('dose') : t('doses')} {t('comparedToRegularPlan')} -

-
-
- ) : ( - - {day.doses.length} {day.doses.length === 1 ? t('dose') : t('doses')} - - )} - {!day.isTemplate && Math.abs(totalMgDiff) > 0.1 ? ( - - - - - -

- {isDailyTotalError - ? `${t('errorDailyTotalAbove200mg').replace('{{total}}', dayTotal.toFixed(1))}` - : isDailyTotalWarning - ? `${t('warningDailyTotalAbove70mg').replace('{{total}}', dayTotal.toFixed(1))}` - : `${totalMgDiff > 0 ? '+' : ''}${totalMgDiff.toFixed(1)} mg ${t('comparedToRegularPlan')}` - } -

-
-
- ) : ( - - {dayTotal.toFixed(1)} mg - - )} -
- {!collapsedDays.has(day.id) && ( - - {/* Daily total warning/error box */} - {(isDailyTotalWarning || isDailyTotalError) && ( -
- - {formatText(isDailyTotalError - ? t('errorDailyTotalAbove200mg').replace('{{total}}', dayTotal.toFixed(1)) - : t('warningDailyTotalAbove70mg').replace('{{total}}', dayTotal.toFixed(1)) - )} -
- )} - {/* Dose table header */} -
-
- {t('time')} - - - - - -

- {isDaySorted(day) ? t('sortByTimeSorted') : t('sortByTimeNeeded')} -

-
-
-
-
{t('ldx')} (mg)
- {/*
- -
*/} -
-
-
- - {/* Dose rows */} - {day.doses.map((dose) => { - // Check for duplicate times - const duplicateTimeCount = day.doses.filter(d => d.time === dose.time).length; - const hasDuplicateTime = duplicateTimeCount > 1; - - // Check for zero dose - const isZeroDose = dose.ldx === '0' || dose.ldx === '0.0'; - // Check for dose > 70 mg - const isHighDose = parseFloat(dose.ldx) > 70; - - // Determine the error/warning message priority: - // 1. Daily total error (> 200mg) - ERROR - // 2. Daily total warning (> 70mg) - WARNING - // 3. Individual dose warning (zero dose or > 70mg) - WARNING - let doseErrorMessage; - let doseWarningMessage; - - if (isDailyTotalError) { - doseErrorMessage = formatText(t('errorDailyTotalAbove200mg').replace('{{total}}', dayTotal.toFixed(1))); - } else if (isDailyTotalWarning) { - doseWarningMessage = formatText(t('warningDailyTotalAbove70mg').replace('{{total}}', dayTotal.toFixed(1))); - } else if (isZeroDose) { - doseWarningMessage = formatText(t('warningZeroDose')); - } else if (isHighDose) { - doseWarningMessage = formatText(t('warningDoseAbove70mg')); + variant="outline" + /> + )} + {!day.isTemplate && ( + onRemoveDay(day.id)} + icon={} + tooltip={t('removeDay')} + size="sm" + variant="outline" + className="text-destructive hover:bg-destructive hover:text-destructive-foreground" + /> + )} + } + > +
+ + {t('day')} {dayIndex + 1} + + {!day.isTemplate && doseCountDiff !== 0 ? ( + + + + + +

+ {doseCountDiff > 0 ? '+' : ''}{doseCountDiff} {Math.abs(doseCountDiff) === 1 ? t('dose') : t('doses')} {t('comparedToRegularPlan')} +

+
+
+ ) : ( + + {day.doses.length} {day.doses.length === 1 ? t('dose') : t('doses')} + + )} + {!day.isTemplate && Math.abs(totalMgDiff) > 0.1 ? ( + + + + + +

+ {isDailyTotalError + ? `${t('errorDailyTotalAbove200mg').replace('{{total}}', dayTotal.toFixed(1))}` + : isDailyTotalWarning + ? `${t('warningDailyTotalAbove70mg').replace('{{total}}', dayTotal.toFixed(1))}` + : `${totalMgDiff > 0 ? '+' : ''}${totalMgDiff.toFixed(1)} mg ${t('comparedToRegularPlan')}` + } +

+
+
+ ) : ( + + {dayTotal.toFixed(1)} mg + + )} +
+ + {!collapsedDays.has(day.id) && ( + + {/* Daily total warning/error box */} + {(isDailyTotalWarning || isDailyTotalError) && ( +
- return ( -
-
- onUpdateDose(day.id, dose.id, 'time', value)} - required={true} - warning={hasDuplicateTime} - errorMessage={formatText(t('errorTimeRequired'))} - warningMessage={formatText(t('warningDuplicateTime'))} - /> - onUpdateDose(day.id, dose.id, 'ldx', value)} - increment={doseIncrement} - min={0} - max={200} - //unit="mg" - required={true} - error={isDailyTotalError} - warning={isDailyTotalWarning || isZeroDose || isHighDose} - errorMessage={doseErrorMessage || formatText(t('errorNumberRequired'))} - warningMessage={doseWarningMessage} - inputWidth="w-[72px]" - /> -
- onUpdateDoseField(day.id, dose.id, 'isFed', !dose.isFed)} - icon={} - tooltip={dose.isFed ? t('doseWithFood') : t('doseFasted')} - size="sm" - variant={dose.isFed ? "default" : "outline"} - className={`h-9 w-9 p-0 ${dose.isFed ? 'bg-orange-500 hover:bg-orange-600' : ''}`} - /> - onRemoveDose(day.id, dose.id)} - icon={} - tooltip={t('removeDose')} - size="sm" - variant="outline" - disabled={day.isTemplate && day.doses.length === 1} - className="h-9 w-9 p-0 text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:border-muted" - /> + {formatText(isDailyTotalError + ? t('errorDailyTotalAbove200mg').replace('{{total}}', dayTotal.toFixed(1)) + : t('warningDailyTotalAbove70mg').replace('{{total}}', dayTotal.toFixed(1)) + )} +
+ )} + {/* Dose table header */} +
+
#
{/* Index header */} +
{t('time')}
{/* Time header */} +
{/* Spacer for delta badge */} +
{t('ldx')} (mg)
{/* LDX header */} +
+ + {/* Dose rows */} + {day.doses.map((dose, doseIdx) => { + // Check for duplicate times + const duplicateTimeCount = day.doses.filter(d => d.time === dose.time).length; + const hasDuplicateTime = duplicateTimeCount > 1; + + // Check for zero dose + const isZeroDose = dose.ldx === '0' || dose.ldx === '0.0'; + // Check for dose > 70 mg + const isHighDose = parseFloat(dose.ldx) > 70; + + // Determine the error/warning message priority: + // 1. Daily total error (> 200mg) - ERROR + // 2. Daily total warning (> 70mg) - WARNING + // 3. Individual dose warning (zero dose or > 70mg) - WARNING + let doseErrorMessage; + let doseWarningMessage; + + if (isDailyTotalError) { + doseErrorMessage = formatText(t('errorDailyTotalAbove200mg').replace('{{total}}', dayTotal.toFixed(1))); + } else if (isDailyTotalWarning) { + doseWarningMessage = formatText(t('warningDailyTotalAbove70mg').replace('{{total}}', dayTotal.toFixed(1))); + } else if (isZeroDose) { + doseWarningMessage = formatText(t('warningZeroDose')); + } else if (isHighDose) { + doseWarningMessage = formatText(t('warningDoseAbove70mg')); + } + + const timeDelta = calculateTimeDelta(dayIndex, doseIdx); + const doseIndex = doseIdx + 1; + + return ( +
+
+
+ {/* Intake index badges */} + + {doseIndex} + + + {/* Time input */} + onUpdateDose(day.id, dose.id, 'time', value)} + onBlur={() => handleTimeBlur(day.id)} + required={true} + warning={hasDuplicateTime} + errorMessage={formatText(t('errorTimeRequired'))} + warningMessage={formatText(t('warningDuplicateTime'))} + /> + + {/* Delta badge */} + + {timeDelta} + + + {/* LDX dose input */} + onUpdateDose(day.id, dose.id, 'ldx', value)} + increment={doseIncrement} + min={0} + max={200} + //unit="mg" + required={true} + error={isDailyTotalError} + warning={isDailyTotalWarning || isZeroDose || isHighDose} + errorMessage={doseErrorMessage || formatText(t('errorNumberRequired'))} + warningMessage={doseWarningMessage} + inputWidth="w-[72px]" + /> +
+ + {/* Action buttons */} +
+ {/* Spacer to align buttons in case of flex wrap only */} +
+ handleActionWithSort(day.id, () => onUpdateDoseField(day.id, dose.id, 'isFed', !dose.isFed))} + icon={} + tooltip={dose.isFed ? t('doseWithFood') : t('doseFasted')} + size="sm" + variant={dose.isFed ? "default" : "outline"} + className={`h-9 w-9 p-0 ${dose.isFed ? 'bg-orange-500 hover:bg-orange-600' : ''}`} + /> + handleActionWithSort(day.id, () => onRemoveDose(day.id, dose.id))} + icon={} + tooltip={t('removeDose')} + size="sm" + variant="outline" + disabled={day.isTemplate && day.doses.length === 1} + className="h-9 w-9 p-0 text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:border-muted" + /> +
-
- ); - })} + ); + })} - {/* Add dose button */} - {day.doses.length < 5 && ( - - )} - - )} - - )})} + {/* Add dose button */} + {day.doses.length < MAX_DOSES_PER_DAY && ( + + )} + + )} + + )})} {/* Add day button */} {canAddDay && ( diff --git a/src/components/settings.tsx b/src/components/settings.tsx index f495201..829f4a7 100644 --- a/src/components/settings.tsx +++ b/src/components/settings.tsx @@ -92,6 +92,7 @@ const Settings = ({ }: any) => { const { showDayTimeOnXAxis, yAxisMin, yAxisMax, showTemplateDay, simulationDays, displayedDays } = uiSettings; const showDayReferenceLines = (uiSettings as any).showDayReferenceLines ?? true; + const showIntakeTimeLines = (uiSettings as any).showIntakeTimeLines ?? false; const showTherapeuticRange = (uiSettings as any).showTherapeuticRange ?? true; const steadyStateDaysEnabled = (uiSettings as any).steadyStateDaysEnabled ?? true; @@ -316,6 +317,35 @@ const Settings = ({
+
+
+ onUpdateUiSetting('showIntakeTimeLines', checked)} + /> + + setOpenTooltipId(open ? 'showIntakeTimeLines' : null)}> + + + + +

{formatContent(tWithDefaults(t, 'showIntakeTimeLinesTooltip', defaultsForT))}

+
+
+
+
+
{ + if (!days || !Array.isArray(days)) return []; + + const times: Array<{ hour: number; dayIndex: number; doseIndex: number }> = []; + const simDaysCount = parseInt(simulationDays, 10) || 3; + + // Iterate through each simulated day + for (let dayNum = 1; dayNum <= simDaysCount; dayNum++) { + // Determine which schedule to use for this day + let daySchedule; + if (dayNum === 1 || days.length === 1) { + // First day or only one schedule exists: use template/first schedule + daySchedule = days.find(d => d.isTemplate) || days[0]; + } else { + // For subsequent days, use the corresponding schedule if it exists, otherwise use template + const scheduleIndex = dayNum - 1; + daySchedule = days[scheduleIndex] || days.find(d => d.isTemplate) || days[0]; + } + + if (daySchedule && daySchedule.doses) { + daySchedule.doses.forEach((dose: any, doseIdx: number) => { + if (dose.time) { + const [hours, minutes] = dose.time.split(':').map(Number); + const hoursSinceStart = (dayNum - 1) * 24 + hours + minutes / 60; + times.push({ + hour: hoursSinceStart, + dayIndex: dayNum, + doseIndex: doseIdx + 1 // 1-based index + }); + } + }); + } + } + + return times; + }, [days, simulationDays]); + // Merge all profiles into a single dataset for proper tooltip synchronization const mergedData = React.useMemo(() => { const dataMap = new Map(); @@ -617,6 +656,43 @@ const SimulationChart = ({ /> )} + {showIntakeTimeLines && intakeTimes.map((intake, idx) => { + // Determine label position offset if day lines are also shown + const labelOffsetY = showDayReferenceLines !== false ? 20 : 5; // More spacing when day lines are shown + + return ( + { + const { viewBox } = props; + // Position at top-right of the reference line with proper offsets + // x: subtract 5px from right edge to create gap between line and text + // y: add offset + ~12px (font size) since y is the text baseline, not top + const x = viewBox.x + viewBox.width - 5; + const y = viewBox.y + labelOffsetY + 12; // 12px ≈ 0.75rem font size + + return ( + + {intake.doseIndex} + + ); + }} + stroke="#c0c0c0" + strokeDasharray="3 3" + xAxisId="hours" + yAxisId="concentration" + /> + ); + })} + {[...Array(parseInt(simulationDays, 10) || 3).keys()].map(day => ( day > 0 && ( ( return (
- ( onKeyDown={handleKeyDown} className={cn( inputWidth, "h-9 z-10", - "rounded-none", + "rounded-r rounded-r-none", getAlignmentClass(), hasError && "error-border focus-visible:ring-destructive", hasWarning && !hasError && "warning-border focus-visible:ring-amber-500" )} {...props} /> +