Add intake auto sorting, chart intake markers, upped max daily intakes to 6, various style changes

This commit is contained in:
2026-02-09 17:08:53 +00:00
parent c41db99cba
commit 7a2a8b0b47
13 changed files with 558 additions and 293 deletions

View File

@@ -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 && (