495 lines
20 KiB
TypeScript
495 lines
20 KiB
TypeScript
/**
|
|
* 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<DayScheduleProps> = ({
|
|
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<Set<string>>(new Set());
|
|
|
|
// Track pending sort timeouts for debounced sorting
|
|
const [pendingSorts, setPendingSorts] = React.useState<Map<string, NodeJS.Timeout>>(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<string>) => {
|
|
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 (
|
|
<div className="space-y-4">
|
|
{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 (
|
|
<Card key={day.id} className="@container">
|
|
<CollapsibleCardHeader
|
|
title={day.isTemplate ? t('regularPlan') : t('alternativePlan')}
|
|
isCollapsed={collapsedDays.has(day.id)}
|
|
onToggle={() => toggleDayCollapse(day.id)}
|
|
toggleLabel={collapsedDays.has(day.id) ? t('expandDay') : t('collapseDay')}
|
|
rightSection={
|
|
<>
|
|
{canAddDay && (
|
|
<IconButtonWithTooltip
|
|
onClick={() => onAddDay(day.id)}
|
|
icon={<Copy className="h-4 w-4" />}
|
|
tooltip={t('cloneDay')}
|
|
size="sm"
|
|
variant="outline"
|
|
/>
|
|
)}
|
|
{!day.isTemplate && (
|
|
<IconButtonWithTooltip
|
|
onClick={() => onRemoveDay(day.id)}
|
|
icon={<Trash2 className="h-4 w-4" />}
|
|
tooltip={t('removeDay')}
|
|
size="sm"
|
|
variant="outline"
|
|
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
|
/>
|
|
)}
|
|
</>
|
|
}
|
|
>
|
|
<div className="flex flex-nowrap items-center gap-2">
|
|
<Badge variant="solid" className="text-xs font-bold">
|
|
{t('day')} {dayIndex + 1}
|
|
</Badge>
|
|
{!day.isTemplate && doseCountDiff !== 0 ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center cursor-help focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-md"
|
|
>
|
|
<Badge
|
|
variant="outline"
|
|
className={`text-xs ${doseCountDiff > 0 ? 'badge-trend-up' : 'badge-trend-down'}`}
|
|
>
|
|
{doseCountDiff > 0 ? <TrendingUp className="h-3 w-3 inline mr-1" /> : <TrendingDown className="h-3 w-3 inline mr-1" />}
|
|
{day.doses.length} {day.doses.length === 1 ? t('dose') : t('doses')}
|
|
</Badge>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p className="text-xs">
|
|
{doseCountDiff > 0 ? '+' : ''}{doseCountDiff} {Math.abs(doseCountDiff) === 1 ? t('dose') : t('doses')} {t('comparedToRegularPlan')}
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<Badge variant="outline" className="text-xs">
|
|
{day.doses.length} {day.doses.length === 1 ? t('dose') : t('doses')}
|
|
</Badge>
|
|
)}
|
|
{!day.isTemplate && Math.abs(totalMgDiff) > 0.1 ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center cursor-help focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded-md"
|
|
>
|
|
<Badge
|
|
variant="outline"
|
|
className={`text-xs ${
|
|
isDailyTotalError
|
|
? 'badge-error'
|
|
: isDailyTotalWarning
|
|
? 'badge-warning'
|
|
: totalMgDiff > 0
|
|
? 'badge-trend-up'
|
|
: 'badge-trend-down'
|
|
}`}
|
|
>
|
|
{!isDailyTotalError && !isDailyTotalWarning && (totalMgDiff > 0 ? <TrendingUp className="h-3 w-3 inline mr-1" /> : <TrendingDown className="h-3 w-3 inline mr-1" />)}
|
|
{dayTotal.toFixed(1)} mg
|
|
</Badge>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p className="text-xs">
|
|
{isDailyTotalError
|
|
? `${t('errorDailyTotalAbove200mg').replace('{{total}}', dayTotal.toFixed(1))}`
|
|
: isDailyTotalWarning
|
|
? `${t('warningDailyTotalAbove70mg').replace('{{total}}', dayTotal.toFixed(1))}`
|
|
: `${totalMgDiff > 0 ? '+' : ''}${totalMgDiff.toFixed(1)} mg ${t('comparedToRegularPlan')}`
|
|
}
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<Badge
|
|
variant="outline"
|
|
className={`text-xs ${
|
|
isDailyTotalError
|
|
? 'badge-error'
|
|
: isDailyTotalWarning
|
|
? 'badge-warning'
|
|
: ''
|
|
}`}
|
|
>
|
|
{dayTotal.toFixed(1)} mg
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</CollapsibleCardHeader>
|
|
|
|
{/* Daily details (intakes) */}
|
|
{!collapsedDays.has(day.id) && (
|
|
<CardContent className="space-y-3">
|
|
{/* Daily total warning/error box */}
|
|
{(isDailyTotalWarning || isDailyTotalError) && (
|
|
<div className={`p-3 rounded-md text-sm ${isDailyTotalError ? 'error-bg-box' : 'warning-bg-box'}`}>
|
|
|
|
{formatText(isDailyTotalError
|
|
? t('errorDailyTotalAbove200mg').replace('{{total}}', dayTotal.toFixed(1))
|
|
: t('warningDailyTotalAbove70mg').replace('{{total}}', dayTotal.toFixed(1))
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* Dose table header */}
|
|
<div className="grid items-center gap-0.5 text-sm font-medium text-muted-foreground" style={{gridTemplateColumns: '20px 172px 72px 1fr'}}>
|
|
<div className="flex justify-center">#</div>{/* Index header */}
|
|
<div>{t('time')}</div>{/* Time header */}
|
|
<div>{t('ldx')} (mg)</div>{/* LDX header */}
|
|
<div></div>{/* Buttons column (empty header) */}
|
|
</div>
|
|
|
|
{/* 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 (
|
|
<div key={dose.id} className="space-y-2">
|
|
<div className="grid items-center gap-0.5" style={{gridTemplateColumns: '20px 172px 72px 1fr'}}>
|
|
{/* Intake index badge */}
|
|
<div className="flex justify-center">
|
|
<Badge variant="solid"
|
|
className="text-xs w-5 h-6 flex items-center justify-center px-1.5">
|
|
{doseIndex}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Time input with delta badge attached (where applicable) */}
|
|
<div className="flex flex-nowrap items-center justify-center gap-0">
|
|
<FormTimeInput
|
|
value={dose.time}
|
|
onChange={(value) => onUpdateDose(day.id, dose.id, 'time', value)}
|
|
onBlur={() => handleTimeBlur(day.id)}
|
|
required={true}
|
|
warning={hasDuplicateTime}
|
|
errorMessage={formatText(t('errorTimeRequired'))}
|
|
warningMessage={formatText(t('warningDuplicateTime'))}
|
|
/>
|
|
<Badge variant={timeDelta ? "field" : "transparent"} className="rounded-l-none border-l-0 font-light italic text-muted-foreground text-xs w-12 h-6 flex justify-end px-1.5">
|
|
{timeDelta}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* LDX dose input */}
|
|
<FormNumericInput
|
|
value={dose.ldx}
|
|
onChange={(value) => 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 */}
|
|
<div className="flex flex-nowrap items-center justify-end gap-1">
|
|
<IconButtonWithTooltip
|
|
onClick={() => handleActionWithSort(day.id, () => onUpdateDoseField(day.id, dose.id, 'isFed', !dose.isFed))}
|
|
icon={<Utensils className="h-4 w-4" />}
|
|
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' : ''}`}
|
|
/>
|
|
<IconButtonWithTooltip
|
|
onClick={() => handleActionWithSort(day.id, () => onRemoveDose(day.id, dose.id))}
|
|
icon={<Trash2 className="h-4 w-4" />}
|
|
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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Add dose button */}
|
|
{day.doses.length < MAX_DOSES_PER_DAY && (
|
|
<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;
|