Add intake auto sorting, chart intake markers, upped max daily intakes to 6, various style changes
This commit is contained in:
16
src/App.tsx
16
src/App.tsx
@@ -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}
|
||||
|
||||
@@ -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,8 +223,11 @@ 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}>
|
||||
<Card key={day.id} className="@container">
|
||||
<CollapsibleCardHeader
|
||||
title={day.isTemplate ? t('regularPlan') : t('alternativePlan')}
|
||||
isCollapsed={collapsedDays.has(day.id)}
|
||||
@@ -147,6 +257,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-nowrap items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t('day')} {dayIndex + 1}
|
||||
</Badge>
|
||||
@@ -226,6 +337,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
||||
{dayTotal.toFixed(1)} mg
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleCardHeader>
|
||||
{!collapsedDays.has(day.id) && (
|
||||
<CardContent className="space-y-3">
|
||||
@@ -240,42 +352,15 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
||||
</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"
|
||||
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 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) => {
|
||||
{day.doses.map((dose, doseIdx) => {
|
||||
// Check for duplicate times
|
||||
const duplicateTimeCount = day.doses.filter(d => d.time === dose.time).length;
|
||||
const hasDuplicateTime = duplicateTimeCount > 1;
|
||||
@@ -302,17 +387,35 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
||||
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 grid-cols-[120px_1fr_auto] sm:grid-cols-[120px_1fr_auto_auto] gap-2 items-center">
|
||||
<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)}
|
||||
@@ -327,9 +430,14 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
||||
warningMessage={doseWarningMessage}
|
||||
inputWidth="w-[72px]"
|
||||
/>
|
||||
<div className="flex gap-2 sm:contents">
|
||||
</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={() => onUpdateDoseField(day.id, dose.id, 'isFed', !dose.isFed)}
|
||||
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"
|
||||
@@ -337,7 +445,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
||||
className={`h-9 w-9 p-0 ${dose.isFed ? 'bg-orange-500 hover:bg-orange-600' : ''}`}
|
||||
/>
|
||||
<IconButtonWithTooltip
|
||||
onClick={() => onRemoveDose(day.id, dose.id)}
|
||||
onClick={() => handleActionWithSort(day.id, () => onRemoveDose(day.id, dose.id))}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
tooltip={t('removeDose')}
|
||||
size="sm"
|
||||
@@ -352,7 +460,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
||||
})}
|
||||
|
||||
{/* Add dose button */}
|
||||
{day.doses.length < 5 && (
|
||||
{day.doses.length < MAX_DOSES_PER_DAY && (
|
||||
<Button
|
||||
onClick={() => onAddDose(day.id)}
|
||||
size="sm"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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] };
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user