Add intake auto sorting, chart intake markers, upped max daily intakes to 6, various style changes
This commit is contained in:
@@ -17,8 +17,9 @@ 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, ArrowDownAZ, TrendingUp, TrendingDown, Utensils } from 'lucide-react';
|
||||
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 {
|
||||
@@ -51,6 +52,123 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
||||
// 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 '+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<DayScheduleProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-4">
|
||||
@@ -116,257 +223,258 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
||||
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 (
|
||||
<Card key={day.id}>
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{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>
|
||||
)}
|
||||
</CollapsibleCardHeader>
|
||||
{!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 grid-cols-[120px_1fr_auto] sm:grid-cols-[120px_1fr_auto_auto] gap-2 text-sm font-medium text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{t('time')}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
<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="ghost"
|
||||
className={
|
||||
isDaySorted(day)
|
||||
? "h-6 w-6 p-0 text-muted-foreground hover:text-muted-foreground cursor-default"
|
||||
: "h-6 w-6 p-0 text-primary hover:text-primary hover:bg-primary/10"
|
||||
}
|
||||
onClick={() => !isDaySorted(day) && onSortDoses(day.id)}
|
||||
disabled={isDaySorted(day)}
|
||||
>
|
||||
<ArrowDownAZ className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">
|
||||
{isDaySorted(day) ? t('sortByTimeSorted') : t('sortByTimeNeeded')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div>{t('ldx')} (mg)</div>
|
||||
{/* <div className="sm:text-center">
|
||||
<Utensils className="h-4 w-4 inline" />
|
||||
</div> */}
|
||||
<div className="hidden sm:block invisible">-</div>
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<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="secondary" className="text-xs">
|
||||
{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>
|
||||
{!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'}`}>
|
||||
|
||||
return (
|
||||
<div key={dose.id} className="space-y-2">
|
||||
<div className="grid grid-cols-[120px_1fr_auto] sm:grid-cols-[120px_1fr_auto_auto] gap-2 items-center">
|
||||
<FormTimeInput
|
||||
value={dose.time}
|
||||
onChange={(value) => onUpdateDose(day.id, dose.id, 'time', value)}
|
||||
required={true}
|
||||
warning={hasDuplicateTime}
|
||||
errorMessage={formatText(t('errorTimeRequired'))}
|
||||
warningMessage={formatText(t('warningDuplicateTime'))}
|
||||
/>
|
||||
<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]"
|
||||
/>
|
||||
<div className="flex gap-2 sm:contents">
|
||||
<IconButtonWithTooltip
|
||||
onClick={() => 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={() => 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"
|
||||
/>
|
||||
{formatText(isDailyTotalError
|
||||
? t('errorDailyTotalAbove200mg').replace('{{total}}', dayTotal.toFixed(1))
|
||||
: t('warningDailyTotalAbove70mg').replace('{{total}}', dayTotal.toFixed(1))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Dose table header */}
|
||||
<div className="flex flex-nowrap items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<div className="w-5 h-6 flex justify-center">#</div>{/* Index header */}
|
||||
<div>{t('time')}</div>{/* Time header */}
|
||||
<div className="w-[8.5rem]"></div> {/* Spacer for delta badge */}
|
||||
<div>{t('ldx')} (mg)</div>{/* LDX 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="flex flex-nowrap @min-[497px]:flex-wrap items-center gap-2">
|
||||
<div className="flex flex-nowrap items-center gap-2">
|
||||
{/* Intake index badges */}
|
||||
<Badge variant="outline" className="text-xs w-5 h-6 flex items-center justify-center px-1.5">
|
||||
{doseIndex}
|
||||
</Badge>
|
||||
|
||||
{/* Time input */}
|
||||
<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'))}
|
||||
/>
|
||||
|
||||
{/* Delta badge */}
|
||||
<Badge variant="outline" className="text-xs w-12 h-6 flex items-center justify-end px-1.5">
|
||||
{timeDelta}
|
||||
</Badge>
|
||||
|
||||
{/* 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]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-nowrap items-center gap-2">
|
||||
{/* Spacer to align buttons in case of flex wrap only */}
|
||||
<div className="w-0 @min-[497px]:w-5 h-9" />
|
||||
<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>
|
||||
</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 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 && (
|
||||
|
||||
Reference in New Issue
Block a user