/** * 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 } from './ui/card'; import { Badge } from './ui/badge'; import { FormTimeInput } from './ui/form-time-input'; import { FormNumericInput } from './ui/form-numeric-input'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { IconButtonWithTooltip } from './ui/icon-button-with-tooltip'; import CollapsibleCardHeader from './ui/collapsible-card-header'; import { Plus, Copy, Trash2, TrendingUp, TrendingDown, Utensils } from 'lucide-react'; import type { DayGroup } from '../constants/defaults'; import { MAX_DOSES_PER_DAY } from '../constants/defaults'; import { formatText } from '../utils/contentFormatter'; 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; onUpdateDoseField: (dayId: string, doseId: string, field: string, value: any) => void; // For non-string fields like isFed onSortDoses: (dayId: string) => void; t: any; } const DaySchedule: React.FC = ({ days, doseIncrement, onAddDay, onRemoveDay, onAddDose, onRemoveDose, onUpdateDose, onUpdateDoseField, onSortDoses, t }) => { const canAddDay = days.length < 3; // 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 ""; // No delta for first dose of first day } 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'); if (savedCollapsed) { try { const collapsedArray = JSON.parse(savedCollapsed); setCollapsedDays(new Set(collapsedArray)); } catch (e) { console.warn('Failed to load collapsed days state:', e); } } }, []); const saveCollapsedDays = (newCollapsedDays: Set) => { localStorage.setItem('dayScheduleCollapsedDays_v1', JSON.stringify(Array.from(newCollapsedDays))); }; const toggleDayCollapse = (dayId: string) => { setCollapsedDays(prev => { const newSet = new Set(prev); if (newSet.has(dayId)) { newSet.delete(dayId); } else { newSet.add(dayId); } saveCollapsedDays(newSet); return newSet; }); }; return (
{days.map((day, dayIndex) => { // Get template day for comparison const templateDay = days.find(d => d.isTemplate); // Calculate daily total const dayTotal = day.doses.reduce((sum, dose) => sum + (parseFloat(dose.ldx) || 0), 0); // Check for daily total warnings/errors const isDailyTotalError = dayTotal > 200; const isDailyTotalWarning = !isDailyTotalError && dayTotal > 70; // Calculate differences for deviation days let doseCountDiff = 0; let totalMgDiff = 0; if (!day.isTemplate && templateDay) { doseCountDiff = day.doses.length - templateDay.doses.length; const templateTotal = templateDay.doses.reduce((sum, dose) => sum + (parseFloat(dose.ldx) || 0), 0); totalMgDiff = dayTotal - templateTotal; } // FIXME incomplete implementation of @container and @min-[497px]: // TODO solution not ideal for mobile, consider https://tailwindcss.com/docs/responsive-design 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 )}
{/* Daily details (intakes) */} {!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 */}
#
{/* Index header */}
{t('time')}
{/* Time header */}
{t('ldx')} (mg)
{/* LDX header */}
{/* Buttons column (empty 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 badge */}
{doseIndex}
{/* Time input with delta badge attached (where applicable) */}
onUpdateDose(day.id, dose.id, 'time', value)} onBlur={() => handleTimeBlur(day.id)} required={true} warning={hasDuplicateTime} errorMessage={formatText(t('errorTimeRequired'))} warningMessage={formatText(t('warningDuplicateTime'))} /> {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 - right aligned */}
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 < MAX_DOSES_PER_DAY && ( )}
)}
)})} {/* Add day button */} {canAddDay && ( )}
); }; export default DaySchedule;