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

@@ -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 (
<TooltipProvider>
<div className="min-h-screen bg-background p-4 sm:p-6 lg:p-8">
<div className="min-h-screen bg-background p-4">{/* sm:p-6 lg:p-8 */}
{/* Disclaimer Modal */}
<DisclaimerModal
isOpen={showDisclaimer}
@@ -191,8 +192,8 @@ const MedPlanAssistant = () => {
/>
<div className="max-w-7xl mx-auto" style={{
// TODO ideally we would have a value around 320px or similar for mobile devices but this causes layout issues (consider e.g. wrapping) and makes the chart hard to read
minWidth: '410px' //minWidth: '320px'
// TODO generally review layout for smaller screens
minWidth: '410px'
}}>
<header className="mb-8">
<div className="flex justify-between items-start gap-4">
@@ -211,10 +212,10 @@ const MedPlanAssistant = () => {
<p className="text-muted-foreground mt-1">{t('appSubtitle')}</p>
</header>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Both Columns - Chart */}
<div className={`xl:col-span-2 bg-card p-6 rounded-lg border min-h-[600px] flex flex-col ${uiSettings.stickyChart ? 'sticky top-2 z-30 shadow-lg' : ''}`}
<div className={`lg:col-span-2 bg-card p-6 rounded-lg border min-h-[600px] flex flex-col ${uiSettings.stickyChart ? 'sticky top-2 z-30 shadow-lg' : ''}`}
style={uiSettings.stickyChart ? { borderColor: 'hsl(var(--primary))' } : {}}>
<div className="flex flex-wrap items-center gap-3 justify-between mb-4">
<div className="flex flex-wrap justify-center gap-2">
@@ -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 = () => {
</div>
{/* Left Column - Controls */}
<div className="xl:col-span-1 space-y-6">
<div className="lg:col-span-1 space-y-6">
<DaySchedule
days={days}
doseIncrement={doseIncrement}
@@ -302,7 +304,7 @@ const MedPlanAssistant = () => {
</div>
{/* Right Column - Settings */}
<div className="xl:col-span-1 space-y-6">
<div className="lg:col-span-1 space-y-6">
<Settings
pkParams={pkParams}
therapeuticRange={therapeuticRange}

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

View File

@@ -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 = ({
</div>
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<Switch
id="showIntakeTimeLines"
checked={showIntakeTimeLines}
onCheckedChange={checked => onUpdateUiSetting('showIntakeTimeLines', checked)}
/>
<Label htmlFor="showIntakeTimeLines" className="font-medium">
{t('showIntakeTimeLines')}
</Label>
<Tooltip open={openTooltipId === 'showIntakeTimeLines'} onOpenChange={(open) => setOpenTooltipId(open ? 'showIntakeTimeLines' : null)}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleTooltipToggle('showIntakeTimeLines')}
onTouchStart={handleTooltipToggle('showIntakeTimeLines')}
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label={t('showIntakeTimeLinesTooltip')}
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'showIntakeTimeLinesTooltip', defaultsForT))}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<Switch

View File

@@ -56,6 +56,7 @@ const SimulationChart = ({
chartView,
showDayTimeOnXAxis,
showDayReferenceLines,
showIntakeTimeLines,
showTherapeuticRange,
therapeuticRange,
simulationDays,
@@ -347,6 +348,44 @@ const SimulationChart = ({
}
}, [days, daysWithDeviations, t]);
// Extract all intake times from all days for intake time reference lines
const intakeTimes = React.useMemo(() => {
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 (
<ReferenceLine
key={`intake-${idx}`}
x={intake.hour}
label={(props: any) => {
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 (
<text
x={x}
y={y}
textAnchor="end"
fontSize="0.75rem"
fontStyle="italic"
fill="#a0a0a0"
>
{intake.doseIndex}
</text>
);
}}
stroke="#c0c0c0"
strokeDasharray="3 3"
xAxisId="hours"
yAxisId="concentration"
/>
);
})}
{[...Array(parseInt(simulationDays, 10) || 3).keys()].map(day => (
day > 0 && (
<ReferenceLine

View File

@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
@@ -15,6 +15,8 @@ const badgeVariants = cva(
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
transparent: "border-transparent bg-transparent text-foreground hover:border-secondary",
inverted: "border-transparent bg-muted-foreground text-background",
},
},
defaultVariants: {

View File

@@ -200,21 +200,6 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
return (
<div ref={containerRef} className={cn("relative flex items-center gap-2", className)}>
<div className="flex items-center">
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"h-9 w-9 rounded-r-none border-r-0",
hasError && "error-border",
hasWarning && !hasError && "warning-border"
)}
onClick={() => updateValue(-1)}
disabled={isAtMin}
tabIndex={-1}
>
<Minus className="h-4 w-4" />
</Button>
<Input
ref={ref}
type="text"
@@ -225,13 +210,28 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
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}
/>
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"h-9 w-9 rounded-l-none rounded-r-none border-l-0",
//hasError && "error-border",
//hasWarning && !hasError && "warning-border"
)}
onClick={() => updateValue(-1)}
disabled={isAtMin}
tabIndex={-1}
>
<Minus className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
@@ -239,8 +239,8 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
className={cn(
"h-9 w-9",
showResetButton ? "rounded-l-none rounded-r-none border-x-0" : "rounded-l-none border-l-0",
hasError && "error-border",
hasWarning && !hasError && "warning-border"
//hasError && "error-border",
//hasWarning && !hasError && "warning-border"
)}
onClick={() => updateValue(1)}
disabled={isAtMax}

View File

@@ -16,9 +16,10 @@ import { Popover, PopoverContent, PopoverTrigger } from "./popover"
import { cn } from "../../lib/utils"
import { useTranslation } from "react-i18next"
interface TimeInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
interface TimeInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'onBlur'> {
value: string
onChange: (value: string) => void
onBlur?: () => void
unit?: string
align?: 'left' | 'center' | 'right'
error?: boolean
@@ -32,6 +33,7 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
({
value,
onChange,
onBlur,
unit,
align = 'center',
error = false,
@@ -89,6 +91,8 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
if (inputValue === '') {
// Update parent with empty value so validation works
onChange('')
// Call optional onBlur callback after internal handling
onBlur?.()
return
}
@@ -111,6 +115,9 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
setDisplayValue(formattedTime)
onChange(formattedTime)
// Call optional onBlur callback after internal handling
onBlur?.()
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -162,6 +169,8 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
// Commit the current value (already updated in real-time) and close
setOriginalValue('') // Clear original so revert doesn't happen on close
setIsPickerOpen(false)
// Call optional onBlur callback after applying picker changes
onBlur?.()
}
const handleCancel = () => {

View File

@@ -31,6 +31,9 @@ export const PROJECT_REPOSITORY_URL = 'https://git.11001001.org/cbaoth/med-plan-
export const APP_VERSION = versionInfo.version;
export const BUILD_INFO = versionInfo;
// UI Configuration
export const MAX_DOSES_PER_DAY = 6; // Maximum number of doses allowed per day
// Pharmacokinetic Constants (from research literature)
// MW ratio: 135.21 (d-amphetamine) / 455.60 (LDX dimesylate) = 0.29677
export const LDX_TO_DAMPH_SALT_FACTOR = 0.29677;
@@ -95,6 +98,7 @@ export interface UiSettings {
simulationDays: string;
displayedDays: string;
showDayReferenceLines?: boolean;
showIntakeTimeLines?: boolean;
showTherapeuticRange?: boolean;
steadyStateDaysEnabled?: boolean;
stickyChart: boolean;
@@ -167,6 +171,7 @@ export const getDefaultState = (): AppState => ({
simulationDays: '5',
displayedDays: '2',
showTherapeuticRange: false,
showIntakeTimeLines: false,
steadyStateDaysEnabled: true,
stickyChart: false,
theme: 'system',

View File

@@ -10,7 +10,7 @@
*/
import React from 'react';
import { LOCAL_STORAGE_KEY, getDefaultState, type AppState, type DayGroup, type DayDose } from '../constants/defaults';
import { LOCAL_STORAGE_KEY, getDefaultState, MAX_DOSES_PER_DAY, type AppState, type DayGroup, type DayDose } from '../constants/defaults';
export const useAppState = () => {
const [appState, setAppState] = React.useState<AppState>(getDefaultState);
@@ -258,13 +258,34 @@ export const useAppState = () => {
...prev,
days: prev.days.map(day => {
if (day.id !== dayId) return day;
if (day.doses.length >= 5) return day; // Max 5 doses per day
if (day.doses.length >= MAX_DOSES_PER_DAY) return day; // Max doses per day
// Calculate dynamic default time: max time + 1 hour, capped at 23:59
let defaultTime = '12:00';
if (!newDose?.time && day.doses.length > 0) {
// Find the latest time in the day
const times = day.doses.map(d => d.time || '00:00');
const maxTime = times.reduce((max, time) => time > max ? time : max, '00:00');
// Parse and add 1 hour
const [hours, minutes] = maxTime.split(':').map(Number);
let newHours = hours + 1;
// Cap at 23:59
if (newHours > 23) {
newHours = 23;
defaultTime = '23:59';
} else {
defaultTime = `${newHours.toString().padStart(2, '0')}:00`;
}
}
const dose: DayDose = {
id: `dose-${Date.now()}-${Math.random()}`,
time: newDose?.time || '12:00',
time: newDose?.time || defaultTime,
ldx: newDose?.ldx || '0',
damph: newDose?.damph || '0',
isFed: newDose?.isFed || false,
};
return { ...day, doses: [...day.doses, dose] };

View File

@@ -98,8 +98,8 @@ export const de = {
simulationSettings: "Simulations-Einstellungen",
showDayReferenceLines: "Tagestrenner anzeigen",
showDayReferenceLinesTooltip: "Vertikale Linien und Statusanzeigen zwischen Tagen anzeigen.\\n\\n__Standard:__ **aktiviert**",
showTherapeuticRangeLines: "Therapeutischen Bereich anzeigen ",
showDayReferenceLinesTooltip: "Vertikale Linien und Statusanzeigen zwischen Tagen anzeigen.\\n\\n__Standard:__ **aktiviert**", showIntakeTimeLines: "Einnahmezeitmarkierungen anzeigen",
showIntakeTimeLinesTooltip: "Vertikale gestrichelte Linien an Einnahmezeiten mit Dosis-Index-Labels anzeigen.\n\n__Standard:__ **deaktiviert**", showTherapeuticRangeLines: "Therapeutischen Bereich anzeigen ",
showTherapeuticRangeLinesTooltip: "Horizontale Referenzlinien für therapeutisches Min/Max anzeigen.\\n\\n__Standard:__ **aktiviert**",
simulationDuration: "Simulationsdauer",
simulationDurationTooltip: "Anzahl der zu simulierenden Tage. Längere Zeiträume zeigen Steady-State.\\n\\n__Standard:__ **{{simulationDays}} Tage**",
@@ -315,7 +315,7 @@ export const de = {
dose: "Dosis",
doses: "Dosen",
comparedToRegularPlan: "verglichen mit regulärem Plan",
time: "Zeit",
time: "Zeitpunkt der Einnahme",
ldx: "LDX",
damph: "d-amph",

View File

@@ -96,8 +96,8 @@ export const en = {
showTemplateDayTooltip: "Display the regular medication plan as reference overlay at all times.\\n\\n__Default:__ **enabled**",
simulationSettings: "Simulation Settings",
showDayReferenceLines: "Show Day Separators",
showDayReferenceLinesTooltip: "Display vertical lines and status indicators separating days.\\n\\n__Default:__ **enabled**",
showTherapeuticRangeLines: "Show Therapeutic Range",
showDayReferenceLinesTooltip: "Display vertical lines and status indicators separating days.\\n\\n__Default:__ **enabled**", showIntakeTimeLines: "Show Intake Time Markers",
showIntakeTimeLinesTooltip: "Display vertical dashed lines at intake times with dose index labels.\n\n__Default:__ **disabled**", showTherapeuticRangeLines: "Show Therapeutic Range",
showTherapeuticRangeLinesTooltip: "Display horizontal reference lines for therapeutic min/max concentrations.\\n\\n__Default:__ **enabled**",
simulationDuration: "Simulation Duration",
simulationDurationTooltip: "Number of days to simulate. Longer periods allow steady-state observation.\\n\\n__Default:__ **{{simulationDays}} days**",
@@ -330,7 +330,7 @@ export const en = {
dose: "dose",
doses: "doses",
comparedToRegularPlan: "compared to regular plan",
time: "Time",
time: "Time of Intake",
ldx: "LDX",
damph: "d-amph",

View File

@@ -94,6 +94,11 @@
@apply !border-amber-500;
}
/* Info border - for input fields with informational messages */
.info-border {
@apply !border-blue-500;
}
/* Error background box - for static error/warning sections */
.error-bg-box {
@apply bg-[hsl(var(--background))] border border-red-500 dark:border-red-500;
@@ -133,6 +138,10 @@
@apply border-amber-500 bg-amber-500/20 text-amber-700 dark:text-amber-300;
}
.badge-info {
@apply border-blue-500 bg-blue-500/20 text-blue-700 dark:text-blue-300;
}
/* Badge variants for trend indicators */
.badge-trend-up {
@apply bg-blue-100 dark:bg-blue-900/60 text-blue-700 dark:text-blue-200;

View File

@@ -20,6 +20,7 @@ export interface ExportData {
showDayTimeOnXAxis: AppState['uiSettings']['showDayTimeOnXAxis'];
showTemplateDay: AppState['uiSettings']['showTemplateDay'];
showDayReferenceLines: AppState['uiSettings']['showDayReferenceLines'];
showIntakeTimeLines: AppState['uiSettings']['showIntakeTimeLines'];
showTherapeuticRange: AppState['uiSettings']['showTherapeuticRange'];
stickyChart: AppState['uiSettings']['stickyChart'];
};
@@ -90,6 +91,7 @@ export const exportSettings = (
showDayTimeOnXAxis: appState.uiSettings.showDayTimeOnXAxis,
showTemplateDay: appState.uiSettings.showTemplateDay,
showDayReferenceLines: appState.uiSettings.showDayReferenceLines ?? true,
showIntakeTimeLines: appState.uiSettings.showIntakeTimeLines ?? false,
showTherapeuticRange: appState.uiSettings.showTherapeuticRange ?? true,
stickyChart: appState.uiSettings.stickyChart,
};
@@ -212,7 +214,7 @@ export const validateImportData = (data: any): ImportValidationResult => {
// Validate diagram settings
if (importData.diagramSettings !== undefined) {
const validFields = ['showDayTimeOnXAxis', 'showTemplateDay', 'showDayReferenceLines', 'showTherapeuticRange', 'stickyChart'];
const validFields = ['showDayTimeOnXAxis', 'showTemplateDay', 'showDayReferenceLines', 'showIntakeTimeLines', 'showTherapeuticRange', 'stickyChart'];
const importedFields = Object.keys(importData.diagramSettings);
const unknownFields = importedFields.filter(f => !validFields.includes(f));
if (unknownFields.length > 0) {
@@ -396,6 +398,7 @@ export const deleteSelectedData = (
newState.uiSettings.showDayTimeOnXAxis = defaults.uiSettings.showDayTimeOnXAxis;
newState.uiSettings.showTemplateDay = defaults.uiSettings.showTemplateDay;
newState.uiSettings.showDayReferenceLines = defaults.uiSettings.showDayReferenceLines;
newState.uiSettings.showIntakeTimeLines = defaults.uiSettings.showIntakeTimeLines;
newState.uiSettings.showTherapeuticRange = defaults.uiSettings.showTherapeuticRange;
newState.uiSettings.stickyChart = defaults.uiSettings.stickyChart;
shouldRemoveMainStorage = true;