Compare commits

...

10 Commits

12 changed files with 526 additions and 132 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "med-plan-assistant",
"version": "0.1.1",
"version": "0.2.0",
"private": true,
"dependencies": {
"@radix-ui/react-label": "^2.1.8",

View File

@@ -36,7 +36,8 @@ const MedPlanAssistant = () => {
removeDay,
addDoseToDay,
removeDoseFromDay,
updateDoseInDay
updateDoseInDay,
sortDosesInDay
} = useAppState();
const {
@@ -112,6 +113,7 @@ const MedPlanAssistant = () => {
displayedDays={displayedDays}
yAxisMin={yAxisMin}
yAxisMax={yAxisMax}
days={days}
t={t}
/>
</div>
@@ -126,6 +128,7 @@ const MedPlanAssistant = () => {
onAddDose={addDoseToDay}
onRemoveDose={removeDoseFromDay}
onUpdateDose={updateDoseInDay}
onSortDoses={sortDosesInDay}
t={t}
/>
</div>

View File

@@ -14,7 +14,8 @@ import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { FormTimeInput } from './ui/form-time-input';
import { FormNumericInput } from './ui/form-numeric-input';
import { Plus, Copy, Trash2 } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { Plus, Copy, Trash2, ArrowDownAZ, ChevronDown, ChevronUp, TrendingUp, TrendingDown } from 'lucide-react';
import type { DayGroup } from '../constants/defaults';
interface DayScheduleProps {
@@ -25,6 +26,7 @@ interface DayScheduleProps {
onAddDose: (dayId: string) => void;
onRemoveDose: (dayId: string, doseId: string) => void;
onUpdateDose: (dayId: string, doseId: string, field: 'time' | 'ldx' | 'damph', value: string) => void;
onSortDoses: (dayId: string) => void;
t: any;
}
@@ -36,23 +38,128 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
onAddDose,
onRemoveDose,
onUpdateDose,
onSortDoses,
t
}) => {
const canAddDay = days.length < 3;
// Track collapsed state for each day (by day ID)
const [collapsedDays, setCollapsedDays] = React.useState<Set<string>>(new Set());
const toggleDayCollapse = (dayId: string) => {
setCollapsedDays(prev => {
const newSet = new Set(prev);
if (newSet.has(dayId)) {
newSet.delete(dayId);
} else {
newSet.add(dayId);
}
return newSet;
});
};
// 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">
{days.map((day, dayIndex) => (
{days.map((day, dayIndex) => {
// Get template day for comparison
const templateDay = days.find(d => d.isTemplate);
// Calculate differences for deviation days
let doseCountDiff = 0;
let totalMgDiff = 0;
if (!day.isTemplate && templateDay) {
doseCountDiff = day.doses.length - templateDay.doses.length;
const dayTotal = day.doses.reduce((sum, dose) => sum + (parseFloat(dose.ldx) || 0), 0);
const templateTotal = templateDay.doses.reduce((sum, dose) => sum + (parseFloat(dose.ldx) || 0), 0);
totalMgDiff = dayTotal - templateTotal;
}
return (
<Card key={day.id}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => toggleDayCollapse(day.id)}
title={collapsedDays.has(day.id) ? t('expandDay') : t('collapseDay')}
>
{collapsedDays.has(day.id) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronUp className="h-4 w-4" />
)}
</Button>
<CardTitle className="text-lg">
{day.isTemplate ? t('regularPlan') : t('deviatingPlan')}
{day.isTemplate ? t('regularPlan') : t('alternativePlan')}
</CardTitle>
<Badge variant="secondary" className="text-xs">
{t('day')} {dayIndex + 1}
</Badge>
{!day.isTemplate && doseCountDiff !== 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={`text-xs ${doseCountDiff > 0 ? 'bg-blue-50' : 'bg-orange-50'}`}
>
{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>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{doseCountDiff > 0 ? '+' : ''}{doseCountDiff} {Math.abs(doseCountDiff) === 1 ? t('dose') : t('doses')} {t('comparedToRegularPlan')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<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 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={`text-xs ${totalMgDiff > 0 ? 'bg-blue-50' : 'bg-orange-50'}`}
>
{totalMgDiff > 0 ? <TrendingUp className="h-3 w-3 inline mr-1" /> : <TrendingDown className="h-3 w-3 inline mr-1" />}
{day.doses.reduce((sum, dose) => sum + (parseFloat(dose.ldx) || 0), 0).toFixed(1)} mg
</Badge>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{totalMgDiff > 0 ? '+' : ''}{totalMgDiff.toFixed(1)} mg {t('comparedToRegularPlan')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Badge variant="outline" className="text-xs">
{day.doses.reduce((sum, dose) => sum + (parseFloat(dose.ldx) || 0), 0).toFixed(1)} mg
</Badge>
)}
</div>
<div className="flex gap-2">
{canAddDay && (
@@ -79,10 +186,38 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
</div>
</div>
</CardHeader>
{!collapsedDays.has(day.id) && (
<CardContent className="space-y-3">
{/* Dose table header */}
<div className="grid grid-cols-[120px_1fr_auto] gap-3 text-sm font-medium text-muted-foreground">
<div>{t('time')}</div>
<div className="flex items-center gap-2">
<span>{t('time')}</span>
<TooltipProvider>
<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>
</TooltipProvider>
</div>
<div>{t('ldx')} (mg)</div>
<div></div>
</div>
@@ -93,6 +228,9 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
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';
return (
<div key={dose.id} className="grid grid-cols-[120px_1fr_auto] gap-3 items-center">
<FormTimeInput
@@ -110,7 +248,9 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
min={0}
unit="mg"
required={true}
warning={isZeroDose}
errorMessage={t('errorNumberRequired')}
warningMessage={t('warningZeroDose')}
/>
<Button
onClick={() => onRemoveDose(day.id, dose.id)}
@@ -139,8 +279,9 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
</Button>
)}
</CardContent>
)}
</Card>
))}
)})}
{/* Add day button */}
{canAddDay && (

View File

@@ -114,7 +114,7 @@ const Settings = ({
increment={1}
min={3}
max={7}
unit={t('days')}
unit={t('unitDays')}
required={true}
errorMessage={t('errorNumberRequired')}
/>
@@ -128,7 +128,7 @@ const Settings = ({
increment={1}
min={1}
max={parseInt(simulationDays, 10) || 3}
unit={t('days')}
unit={t('unitDays')}
required={true}
errorMessage={t('errorNumberRequired')}
/>
@@ -140,7 +140,7 @@ const Settings = ({
<FormNumericInput
value={yAxisMin}
onChange={val => onUpdateUiSetting('yAxisMin', val)}
increment={5}
increment={1}
min={0}
placeholder={t('auto')}
allowEmpty={true}

View File

@@ -46,6 +46,7 @@ const SimulationChart = ({
displayedDays,
yAxisMin,
yAxisMax,
days,
t
}: any) => {
const totalHours = (parseInt(simulationDays, 10) || 3) * 24;
@@ -79,6 +80,62 @@ const SimulationChart = ({
return [domainMin, domainMax];
}, [yAxisMin, yAxisMax]);
// Check which days have deviations (differ from template)
const daysWithDeviations = React.useMemo(() => {
if (!templateProfile || !combinedProfile) return new Set<number>();
const deviatingDays = new Set<number>();
const simDays = parseInt(simulationDays, 10) || 3;
// Check each day starting from day 2 (day 1 is always template)
for (let day = 2; day <= simDays; day++) {
const dayStartHour = (day - 1) * 24;
const dayEndHour = day * 24;
// Sample points in this day to check for differences
// Check every hour in the day
for (let hour = dayStartHour; hour < dayEndHour; hour++) {
const combinedPoint = combinedProfile.find((p: any) => Math.abs(p.timeHours - hour) < 0.1);
const templatePoint = templateProfile.find((p: any) => Math.abs(p.timeHours - hour) < 0.1);
if (combinedPoint && templatePoint) {
// Consider it different if values differ by more than 0.01 (tolerance for floating point)
const damphDiff = Math.abs(combinedPoint.damph - templatePoint.damph);
const ldxDiff = Math.abs(combinedPoint.ldx - templatePoint.ldx);
if (damphDiff > 0.01 || ldxDiff > 0.01) {
deviatingDays.add(day);
break; // Found deviation in this day, no need to check more hours
}
}
}
}
return deviatingDays;
}, [combinedProfile, templateProfile, simulationDays]);
// Determine label for each day's reference line
const getDayLabel = React.useCallback((dayNumber: number) => {
if (dayNumber === 1) return t('refLineRegularPlan');
// Check if this day has an actual schedule entry (not auto-filled)
const hasSchedule = days && days.length >= dayNumber;
// Check if this day deviates from template
const hasDeviation = daysWithDeviations.has(dayNumber);
if (!hasDeviation) {
// Matches template
return t('refLineNoDeviation');
} else if (!hasSchedule) {
// Deviates but no schedule = recovering
return t('refLineRecovering');
} else {
// Has deviation and has schedule = actual irregular intake
return t('refLineIrregularIntake');
}
}, [days, daysWithDeviations, t]);
// Merge all profiles into a single dataset for proper tooltip synchronization
const mergedData = React.useMemo(() => {
const dataMap = new Map();
@@ -93,17 +150,23 @@ const SimulationChart = ({
});
// Add template profile data (regular plan only) if provided
// Only include points for days that have deviations
templateProfile?.forEach((point: any) => {
const existing = dataMap.get(point.timeHours) || { timeHours: point.timeHours };
dataMap.set(point.timeHours, {
...existing,
templateDamph: point.damph,
templateLdx: point.ldx
});
const pointDay = Math.ceil(point.timeHours / 24);
// Only include template data for days with deviations
if (daysWithDeviations.has(pointDay)) {
const existing = dataMap.get(point.timeHours) || { timeHours: point.timeHours };
dataMap.set(point.timeHours, {
...existing,
templateDamph: point.damph,
templateLdx: point.ldx
});
}
});
return Array.from(dataMap.values()).sort((a, b) => a.timeHours - b.timeHours);
}, [combinedProfile, templateProfile]);
}, [combinedProfile, templateProfile, daysWithDeviations]);
// Calculate chart dimensions
const [containerWidth, setContainerWidth] = React.useState(1000);
@@ -143,6 +206,11 @@ const SimulationChart = ({
align="left"
height={36}
wrapperStyle={{ paddingLeft: 0 }}
formatter={(value: string) => {
// Apply lighter color to template overlay entries in legend
const isTemplate = value.includes(t('regularPlanOverlay'));
return <span style={{ opacity: isTemplate ? 0.5 : 1 }}>{value}</span>;
}}
/>
{/* Invisible lines just to show in legend */}
{(chartView === 'damph' || chartView === 'both') && (
@@ -166,7 +234,7 @@ const SimulationChart = ({
strokeOpacity={0}
/>
)}
{templateProfile && (chartView === 'damph' || chartView === 'both') && (
{templateProfile && daysWithDeviations.size > 0 && (chartView === 'damph' || chartView === 'both') && (
<Line
dataKey="templateDamph"
name={`${t('dAmphetamine')} (${t('regularPlanOverlay')})`}
@@ -175,9 +243,10 @@ const SimulationChart = ({
strokeDasharray="3 3"
dot={false}
strokeOpacity={0}
opacity={0.5}
/>
)}
{templateProfile && (chartView === 'ldx' || chartView === 'both') && (
{templateProfile && daysWithDeviations.size > 0 && (chartView === 'ldx' || chartView === 'both') && (
<Line
dataKey="templateLdx"
name={`${t('lisdexamfetamine')} (${t('regularPlanOverlay')})`}
@@ -186,6 +255,7 @@ const SimulationChart = ({
strokeDasharray="3 3"
dot={false}
strokeOpacity={0}
opacity={0.5}
/>
)}
</LineChart>
@@ -235,7 +305,7 @@ const SimulationChart = ({
};
return <XAxis
xAxisId="hours"
label={{ value: showDayTimeOnXAxis === 'continuous' ? t('axisLabelHours') : t('axisLabelTimeOfDay'), position: 'insideBottom', offset: -10, style: { fontStyle: 'italic', color: '#666' } }}
//label={{ value: showDayTimeOnXAxis === 'continuous' ? t('axisLabelHours') : t('axisLabelTimeOfDay'), position: 'insideBottom', offset: -10, style: { fontStyle: 'italic', color: '#666' } }}
dataKey="timeHours"
type="number"
domain={[0, totalHours]}
@@ -248,17 +318,57 @@ const SimulationChart = ({
<YAxis
yAxisId="concentration"
label={{ value: t('axisLabelConcentration'), angle: -90, position: 'insideLeft', offset: '0 -10', style: { fontStyle: 'italic', color: '#666' } }}
// FIXME
//label={{ value: t('axisLabelConcentration'), angle: -90, position: 'insideLeft', style: { fontStyle: 'italic', color: '#666' } }}
domain={chartDomain as any}
allowDecimals={false}
tickCount={20}
/>
<Tooltip
formatter={(value: any, name) => [`${typeof value === 'number' ? value.toFixed(1) : value} ${t('ngml')}`, name]}
labelFormatter={(label, payload) => {
content={({ active, payload, label }) => {
if (!active || !payload || payload.length === 0) return null;
// Extract timeHours from the payload data point
const timeHours = payload?.[0]?.payload?.timeHours ?? label;
return `${t('hour').replace('h', 'Hour')}: ${timeHours}${t('hour')}`;
const timeHours = payload[0]?.payload?.timeHours ?? label;
const h = typeof timeHours === 'number' ? timeHours : parseFloat(timeHours);
// Format time to match x-axis format
let timeLabel: string;
if (showDayTimeOnXAxis === '24h') {
timeLabel = `${h % 24}${t('unitHour')}`;
} else if (showDayTimeOnXAxis === '12h') {
const hour12 = h % 24;
if (hour12 === 12) {
timeLabel = t('tickNoon');
} else {
const displayHour = hour12 === 0 ? 12 : hour12 > 12 ? hour12 - 12 : hour12;
const period = hour12 < 12 ? 'a' : 'p';
timeLabel = `${displayHour}${period}`;
}
} else {
timeLabel = `${h}${t('unitHour')}`;
}
return (
<div className="recharts-default-tooltip" style={{ margin: 0, padding: 10, backgroundColor: 'rgb(255, 255, 255)', border: '1px solid rgb(204, 204, 204)', whiteSpace: 'nowrap' }}>
<p className="recharts-tooltip-label" style={{ margin: 0 }}>{t('time')}: {timeLabel}</p>
<ul className="recharts-tooltip-item-list" style={{ padding: 0, margin: 0 }}>
{payload.map((entry: any, index: number) => {
const isTemplate = entry.name?.includes(t('regularPlanOverlay'));
const opacity = isTemplate ? 0.5 : 1;
const value = typeof entry.value === 'number' ? entry.value.toFixed(1) : entry.value;
return (
<li key={`item-${index}`} className="recharts-tooltip-item" style={{ display: 'block', paddingTop: 4, paddingBottom: 4, color: entry.color, opacity }}>
<span className="recharts-tooltip-item-name">{entry.name}</span>
<span className="recharts-tooltip-item-separator">: </span>
<span className="recharts-tooltip-item-value">{value} {t('unitNgml')}</span>
</li>
);
})}
</ul>
</div>
);
}}
wrapperStyle={{ pointerEvents: 'none', zIndex: 200 }}
allowEscapeViewBox={{ x: false, y: false }}
@@ -267,12 +377,12 @@ const SimulationChart = ({
/>
<CartesianGrid strokeDasharray="1 1" xAxisId="hours" yAxisId="concentration" />
{showDayReferenceLines !== false && [...Array(dispDays).keys()].map(day => (
{showDayReferenceLines !== false && [...Array(dispDays+1).keys()].map(day => (
<ReferenceLine
key={`day-${day+1}`}
x={24 * (day+1)}
label={{
value: (day === 0 ? t('refLineRegularPlan') : t('refLineDeviatingPlan')) + ' (' + t('refLineDayX', { x: day+1 }) + ')',
value: t('refLineDayX', { x: day+1 }) + '' + getDayLabel(day + 1),
position: 'insideTopRight',
style: {
fontSize: '0.75rem',
@@ -351,7 +461,7 @@ const SimulationChart = ({
<Line
type="monotone"
dataKey="templateDamph"
name={`${t('dAmphetamine')} (${t('regularPlan')} ${t('continuation')})`}
name={`${t('dAmphetamine')} (${t('regularPlanOverlay')})`}
stroke={CHART_COLORS.idealDamph}
strokeWidth={2}
strokeDasharray="3 3"
@@ -366,7 +476,7 @@ const SimulationChart = ({
<Line
type="monotone"
dataKey="templateLdx"
name={`${t('lisdexamfetamine')} (${t('regularPlan')} ${t('continuation')})`}
name={`${t('lisdexamfetamine')} (${t('regularPlanOverlay')})`}
stroke={CHART_COLORS.idealLdx}
strokeWidth={1.5}
strokeDasharray="3 3"

View File

@@ -13,6 +13,7 @@ import { Minus, Plus, X } from "lucide-react"
import { Button } from "./button"
import { Input } from "./input"
import { cn } from "../../lib/utils"
import { useTranslation } from "react-i18next"
interface NumericInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
value: string | number
@@ -50,9 +51,11 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
className,
...props
}, ref) => {
const [showError, setShowError] = React.useState(false)
const [showWarning, setShowWarning] = React.useState(false)
const { t } = useTranslation()
const [, setShowError] = React.useState(false)
const [, setShowWarning] = React.useState(false)
const [touched, setTouched] = React.useState(false)
const [isFocused, setIsFocused] = React.useState(false)
const containerRef = React.useRef<HTMLDivElement>(null)
// Check if value is invalid (check validity regardless of touch state)
@@ -114,24 +117,41 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
}
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const val = e.target.value
const inputValue = e.target.value.trim()
setTouched(true)
setIsFocused(false)
setShowError(false)
setShowWarning(false)
if (val === '' && !allowEmpty) {
if (inputValue === '' && !allowEmpty) {
// Update parent with empty value so validation works
onChange('')
return
}
if (val !== '' && !isNaN(Number(val))) {
onChange(formatValue(val))
if (inputValue !== '' && !isNaN(Number(inputValue))) {
onChange(formatValue(inputValue))
}
}
const handleFocus = () => {
setIsFocused(true)
setShowError(hasError)
setShowWarning(hasWarning)
}
// Ensure value is consistently formatted to the required decimal places
React.useEffect(() => {
const strVal = String(value)
if (strVal === '') return
const num = Number(strVal)
if (isNaN(num)) return
const formatted = num.toFixed(decimalPlaces)
if (strVal !== formatted) {
onChange(formatted)
}
}, [value, decimalPlaces, onChange])
const getAlignmentClass = () => {
switch (align) {
case 'left': return 'text-left'
@@ -150,7 +170,8 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
size="icon"
className={cn(
"h-9 w-9 rounded-r-none border-r-0",
hasError && "border-destructive"
hasError && "border-destructive",
hasWarning && !hasError && "border-yellow-500"
)}
onClick={() => updateValue(-1)}
tabIndex={-1}
@@ -169,7 +190,8 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
"w-20 h-9 z-20",
"rounded-none",
getAlignmentClass(),
hasError && "border-destructive focus-visible:ring-destructive"
hasError && "border-destructive focus-visible:ring-destructive",
hasWarning && !hasError && "border-yellow-500 focus-visible:ring-yellow-500"
)}
{...props}
/>
@@ -180,7 +202,8 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
className={cn(
"h-9 w-9",
clearButton && allowEmpty ? "rounded-l-none rounded-r-none border-x-0" : "rounded-l-none border-l-0",
hasError && "border-destructive"
hasError && "border-destructive",
hasWarning && !hasError && "border-yellow-500"
)}
onClick={() => updateValue(1)}
tabIndex={-1}
@@ -194,23 +217,25 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
size="icon"
className={cn(
"h-9 w-9 rounded-l-none",
hasError && "border-destructive"
hasError && "border-destructive",
hasWarning && !hasError && "border-yellow-500"
)}
onClick={() => onChange('')}
tabIndex={-1}
title={ t('buttonClear') }
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
{hasError && showError && errorMessage && (
{hasError && isFocused && errorMessage && (
<div className="absolute top-full left-0 mt-1 z-50 w-64 bg-destructive text-destructive-foreground text-xs p-2 rounded-md shadow-lg">
{errorMessage}
</div>
)}
{hasWarning && showWarning && warningMessage && (
<div className="absolute top-full left-0 mt-1 z-50 w-48 bg-yellow-500 text-yellow-950 text-xs p-2 rounded-md shadow-lg">
{hasWarning && isFocused && warningMessage && (
<div className="absolute top-full left-0 mt-1 z-50 w-48 bg-yellow-500 text-white text-xs p-2 rounded-md shadow-lg">
{warningMessage}
</div>
)}

View File

@@ -14,6 +14,7 @@ import { Button } from "./button"
import { Input } from "./input"
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'> {
value: string
@@ -41,13 +42,22 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
className,
...props
}, ref) => {
const { t } = useTranslation()
const [displayValue, setDisplayValue] = React.useState(value)
const [isPickerOpen, setIsPickerOpen] = React.useState(false)
const [showError, setShowError] = React.useState(false)
const [showWarning, setShowWarning] = React.useState(false)
const [, setShowError] = React.useState(false)
const [, setShowWarning] = React.useState(false)
const [touched, setTouched] = React.useState(false)
const [isFocused, setIsFocused] = React.useState(false)
const containerRef = React.useRef<HTMLDivElement>(null)
// Current committed value parsed from prop
const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number)
// Staged selections (pending confirmation)
const [stagedHour, setStagedHour] = React.useState<number | null>(null)
const [stagedMinute, setStagedMinute] = React.useState<number | null>(null)
// Check if value is invalid (check validity regardless of touch state)
const isInvalid = required && (!value || value.trim() === '')
const hasError = error || isInvalid
@@ -57,9 +67,21 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
setDisplayValue(value)
}, [value])
// Align error bubble behavior with numeric input: show when invalid after first blur
React.useEffect(() => {
if (isInvalid && touched) {
setShowError(true)
} else if (!isInvalid) {
setShowError(false)
}
}, [isInvalid, touched])
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const inputValue = e.target.value.trim()
setTouched(true)
setIsFocused(false)
setShowError(false)
setShowWarning(false)
if (inputValue === '') {
// Update parent with empty value so validation works
@@ -98,21 +120,41 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
}
const handleFocus = () => {
setIsFocused(true)
setShowError(hasError)
setShowWarning(hasWarning)
}
const handlePickerChange = (part: 'h' | 'm', val: number) => {
let newHours = pickerHours, newMinutes = pickerMinutes
if (part === 'h') {
newHours = val
} else {
newMinutes = val
const handlePickerOpen = (open: boolean) => {
setIsPickerOpen(open)
if (open) {
// Reset staging when opening picker
setStagedHour(null)
setStagedMinute(null)
}
const formattedTime = `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`
onChange(formattedTime)
}
const handleHourClick = (hour: number) => {
setStagedHour(hour)
}
const handleMinuteClick = (minute: number) => {
setStagedMinute(minute)
}
const handleApply = () => {
// Use staged values if selected, otherwise keep current values
const finalHour = stagedHour !== null ? stagedHour : pickerHours
const finalMinute = stagedMinute !== null ? stagedMinute : pickerMinutes
const formattedTime = `${String(finalHour).padStart(2, '0')}:${String(finalMinute).padStart(2, '0')}`
onChange(formattedTime)
setIsPickerOpen(false)
}
// Apply button is enabled when both hour and minute have valid values (either staged or from current value)
const canApply = (stagedHour !== null || pickerHours !== undefined) &&
(stagedMinute !== null || pickerMinutes !== undefined)
const getAlignmentClass = () => {
switch (align) {
case 'left': return 'text-left'
@@ -142,7 +184,7 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
)}
{...props}
/>
<Popover open={isPickerOpen} onOpenChange={setIsPickerOpen}>
<Popover open={isPickerOpen} onOpenChange={handlePickerOpen}>
<PopoverTrigger asChild>
<Button
type="button"
@@ -158,59 +200,73 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-3 bg-popover shadow-md border">
<div className="flex gap-2">
<div className="flex flex-col gap-1">
<div className="text-xs font-medium text-center mb-1">Hour</div>
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto">
{Array.from({ length: 24 }, (_, i) => (
<Button
key={i}
type="button"
variant={pickerHours === i ? "default" : "outline"}
size="sm"
className="h-8 w-10"
onClick={() => {
handlePickerChange('h', i)
setIsPickerOpen(false)
}}
>
{String(i).padStart(2, '0')}
</Button>
))}
<div className="flex flex-col gap-3">
<div className="flex gap-2">
<div className="flex flex-col gap-1">
<div className="text-xs font-medium text-center mb-1">{t('timePickerHour')}</div>
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto">
{Array.from({ length: 24 }, (_, i) => {
const isCurrentValue = pickerHours === i && stagedHour === null
const isStaged = stagedHour === i
return (
<Button
key={i}
type="button"
variant={isStaged ? "default" : isCurrentValue ? "secondary" : "outline"}
size="sm"
className="h-8 w-10"
onClick={() => handleHourClick(i)}
>
{String(i).padStart(2, '0')}
</Button>
)
})}
</div>
</div>
<div className="flex flex-col gap-1">
<div className="text-xs font-medium text-center mb-1">{t('timePickerMinute')}</div>
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto">
{Array.from({ length: 12 }, (_, i) => i * 5).map(minute => {
const isCurrentValue = pickerMinutes === minute && stagedMinute === null
const isStaged = stagedMinute === minute
return (
<Button
key={minute}
type="button"
variant={isStaged ? "default" : isCurrentValue ? "secondary" : "outline"}
size="sm"
className="h-8 w-10"
onClick={() => handleMinuteClick(minute)}
>
{String(minute).padStart(2, '0')}
</Button>
)
})}
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<div className="text-xs font-medium text-center mb-1">Min</div>
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto">
{Array.from({ length: 12 }, (_, i) => i * 5).map(minute => (
<Button
key={minute}
type="button"
variant={pickerMinutes === minute ? "default" : "outline"}
size="sm"
className="h-8 w-10"
onClick={() => {
handlePickerChange('m', minute)
setIsPickerOpen(false)
}}
>
{String(minute).padStart(2, '0')}
</Button>
))}
</div>
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={handleApply}
disabled={!canApply}
>
{t('timePickerApply')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
{hasError && showError && errorMessage && (
{hasError && isFocused && errorMessage && (
<div className="absolute top-full left-0 mt-1 z-50 w-48 bg-destructive text-destructive-foreground text-xs p-2 rounded-md shadow-lg">
{errorMessage}
</div>
)}
{hasWarning && showWarning && warningMessage && (
<div className="absolute top-full left-0 mt-1 z-50 w-48 bg-yellow-500 text-yellow-950 text-xs p-2 rounded-md shadow-lg">
{hasWarning && isFocused && warningMessage && (
<div className="absolute top-full left-0 mt-1 z-50 w-48 bg-yellow-500 text-white text-xs p-2 rounded-md shadow-lg">
{warningMessage}
</div>
)}

View File

@@ -99,12 +99,12 @@ export const getDefaultState = (): AppState => ({
therapeuticRange: { min: '10.5', max: '11.5' },
doseIncrement: '2.5',
uiSettings: {
showDayTimeOnXAxis: 'continuous',
showTemplateDay: false,
chartView: 'both',
yAxisMin: '0',
yAxisMax: '16',
simulationDays: '3',
displayedDays: '2',
showDayTimeOnXAxis: '24h',
showTemplateDay: true,
chartView: 'damph',
yAxisMin: '8',
yAxisMax: '13',
simulationDays: '5',
displayedDays: '5',
}
});

View File

@@ -186,20 +186,11 @@ export const useAppState = () => {
days: prev.days.map(day => {
if (day.id !== dayId) return day;
// Update the dose field
// Update the dose field (no auto-sort)
const updatedDoses = day.doses.map(dose =>
dose.id === doseId ? { ...dose, [field]: value } : dose
);
// Sort by time if time field was changed
if (field === 'time') {
updatedDoses.sort((a, b) => {
const timeA = a.time || '00:00';
const timeB = b.time || '00:00';
return timeA.localeCompare(timeB);
});
}
return {
...day,
doses: updatedDoses
@@ -208,6 +199,26 @@ export const useAppState = () => {
}));
};
const sortDosesInDay = (dayId: string) => {
setAppState(prev => ({
...prev,
days: prev.days.map(day => {
if (day.id !== dayId) return day;
const sortedDoses = [...day.doses].sort((a, b) => {
const timeA = a.time || '00:00';
const timeB = b.time || '00:00';
return timeA.localeCompare(timeB);
});
return {
...day,
doses: sortedDoses
};
})
}));
};
const handleReset = () => {
if (window.confirm("Bist du sicher, dass du alle Einstellungen auf die Standardwerte zurücksetzen möchtest? Dies kann nicht rückgängig gemacht werden.")) {
window.localStorage.removeItem(LOCAL_STORAGE_KEY);
@@ -227,6 +238,7 @@ export const useAppState = () => {
addDoseToDay,
removeDoseFromDay,
updateDoseInDay,
sortDosesInDay,
handleReset
};
};

View File

@@ -44,10 +44,14 @@ export const de = {
axisLabelTimeOfDay: "Tageszeit (h)",
tickNoon: "Mittag",
refLineRegularPlan: "Regulärer Plan",
refLineDeviatingPlan: "Abweichung",
refLineDeviatingPlan: "Abweichung vom Plan",
refLineNoDeviation: "Keine Abweichung",
refLineRecovering: "Erholung",
refLineIrregularIntake: "Irreguläre Einnahme",
refLineDayX: "Tag {{x}}",
refLineMin: "Min",
refLineMax: "Max",
tooltipHour: "Stunde",
// Settings
diagramSettings: "Diagramm-Einstellungen",
@@ -58,10 +62,9 @@ export const de = {
xAxisFormat24hDesc: "Wiederholender 0-24h Zyklus",
xAxisFormat12h: "Tageszeit (12h AM/PM)",
xAxisFormat12hDesc: "Wiederholend 12h Zyklus im AM/PM Format",
showTemplateDayInChart: "Regulären Plan kontinuierlich im Diagramm anzeigen",
showDayReferenceLines: "Tagestrenner anzeigen",
showTemplateDayInChart: "Regulären Plan einblenden (nur bei abweichenden Tagen)",
showDayReferenceLines: "Tagestrenner anzeigen (Referenzlinien und Status)",
simulationDuration: "Simulationsdauer",
days: "Tage",
displayedDays: "Sichtbare Tage (im Fokus)",
yAxisRange: "Y-Achsen-Bereich (Zoom)",
yAxisRangeAutoButton: "A",
@@ -70,7 +73,6 @@ export const de = {
therapeuticRange: "Therapeutischer Bereich (Referenzlinien)",
dAmphetamineParameters: "d-Amphetamin Parameter",
halfLife: "Halbwertszeit",
hours: "h",
lisdexamfetamineParameters: "Lisdexamfetamin Parameter",
conversionHalfLife: "Umwandlungs-Halbwertszeit",
absorptionRate: "Absorptionsrate",
@@ -78,8 +80,10 @@ export const de = {
resetAllSettings: "Alle Einstellungen zurücksetzen",
// Units
mg: "mg",
ngml: "ng/ml",
unitMg: "mg",
unitNgml: "ng/ml",
unitHour: "h",
unitDays: "Tage",
// Reset confirmation
resetConfirmation: "Bist du sicher, dass du alle Einstellungen auf die Standardwerte zurücksetzen möchtest? Dies kann nicht rückgängig gemacht werden.",
@@ -88,14 +92,19 @@ export const de = {
importantNote: "Wichtiger Hinweis",
disclaimer: "Dieses Tool dient ausschließlich zu Illustrations- und Informationszwecken. Es ist kein medizinisches Gerät und ersetzt nicht die Beratung durch einen Arzt oder Apotheker. Alle Berechnungen sind Simulationen, die auf allgemeinen pharmakokinetischen Modellen basieren und von individuellen Faktoren erheblich abweichen können. Bitte konsultiere deinen behandelnden Arzt, bevor du Anpassungen an deiner Medikation vornimmst.",
// Number input field
buttonClear: "Feld löschen",
// Field validation
errorNumberRequired: "Bitte gib eine gültige Zahl ein.",
errorTimeRequired: "Bitte gib eine gültige Zeitangabe ein.",
warningDuplicateTime: "Mehrere Dosen zur gleichen Zeit.",
warningZeroDose: "Nulldosis hat keine Auswirkung auf die Simulation.",
// Day-based schedule
regularPlan: "Regulärer Plan",
deviatingPlan: "Abweichung vom Plan",
alternativePlan: "Alternativer Plan",
regularPlanOverlay: "Regulär",
dayNumber: "Tag {{number}}",
cloneDay: "Tag klonen",
@@ -103,6 +112,11 @@ export const de = {
addDose: "Dosis hinzufügen",
removeDose: "Dosis entfernen",
removeDay: "Tag entfernen",
collapseDay: "Tag einklappen",
expandDay: "Tag ausklappen",
dose: "Dosis",
doses: "Dosen",
comparedToRegularPlan: "verglichen mit regulärem Plan",
time: "Zeit",
ldx: "LDX",
damph: "d-amph",
@@ -112,7 +126,17 @@ export const de = {
viewingSharedPlan: "Du siehst einen geteilten Plan",
saveAsMyPlan: "Als meinen Plan speichern",
discardSharedPlan: "Verwerfen",
planCopiedToClipboard: "Plan-Link in Zwischenablage kopiert!"
planCopiedToClipboard: "Plan-Link in Zwischenablage kopiert!",
// Time picker
timePickerHour: "Stunde",
timePickerMinute: "Minute",
timePickerApply: "Übernehmen",
// Sorting
sortByTime: "Nach Zeit sortieren",
sortByTimeNeeded: "Dosen sind nicht in chronologischer Reihenfolge. Klicken zum Sortieren.",
sortByTimeSorted: "Dosen sind chronologisch sortiert."
};
export default de;

View File

@@ -44,7 +44,10 @@ export const en = {
axisLabelTimeOfDay: "Time of Day (h)",
tickNoon: "Noon",
refLineRegularPlan: "Regular Plan",
refLineDeviatingPlan: "Deviation",
refLineDeviatingPlan: "Deviation from Plan",
refLineNoDeviation: "No Deviation",
refLineRecovering: "Recovering",
refLineIrregularIntake: "Irregular Intake",
refLineDayX: "Day {{x}}",
refLineMin: "Min",
refLineMax: "Max",
@@ -58,10 +61,9 @@ export const en = {
xAxisFormat24hDesc: "Repeating 0-24h cycle",
xAxisFormat12h: "Time of Day (12h AM/PM)",
xAxisFormat12hDesc: "Repeating 12h cycle in AM/PM format",
showTemplateDayInChart: "Overlay regular plan in chart",
showDayReferenceLines: "Show day separators",
showTemplateDayInChart: "Show Regular Plan (Only for Deviating Days)",
showDayReferenceLines: "Show Day Separators (Reference Lines and Status)",
simulationDuration: "Simulation Duration",
days: "Days",
displayedDays: "Visible Days (in Focus)",
yAxisRange: "Y-Axis Range (Zoom)",
yAxisRangeAutoButton: "A",
@@ -70,7 +72,6 @@ export const en = {
therapeuticRange: "Therapeutic Range (Reference Lines)",
dAmphetamineParameters: "d-Amphetamine Parameters",
halfLife: "Half-life",
hours: "h",
lisdexamfetamineParameters: "Lisdexamfetamine Parameters",
conversionHalfLife: "Conversion Half-life",
absorptionRate: "Absorption Rate",
@@ -78,8 +79,10 @@ export const en = {
resetAllSettings: "Reset All Settings",
// Units
mg: "mg",
ngml: "ng/ml",
unitMg: "mg",
unitNgml: "ng/ml",
unitHour: "h",
unitDays: "Days",
// Reset confirmation
resetConfirmation: "Are you sure you want to reset all settings to default values? This cannot be undone.",
@@ -88,14 +91,29 @@ export const en = {
importantNote: "Important Notice",
disclaimer: "This tool is for illustration and information purposes only. It is not a medical device and does not replace consultation with a doctor or pharmacist. All calculations are simulations based on general pharmacokinetic models and may differ significantly from individual factors. Please consult your treating physician before making adjustments to your medication.",
// Number input field
buttonClear: "Clear field",
// Field validation
errorNumberRequired: "Please enter a valid number.",
errorTimeRequired: "Please enter a valid time.",
warningDuplicateTime: "Multiple doses at same time.",
warningZeroDose: "Zero dose has no effect on simulation.",
// Time picker
timePickerHour: "Hour",
timePickerMinute: "Minute",
timePickerApply: "Apply",
// Sorting
sortByTime: "Sort by time",
sortByTimeNeeded: "Doses are not in chronological order. Click to sort.",
sortByTimeSorted: "Doses are sorted chronologically.",
// Day-based schedule
regularPlan: "Regular Plan",
deviatingPlan: "Deviation from Plan",
alternativePlan: "Alternative Plan",
regularPlanOverlay: "Regular",
dayNumber: "Day {{number}}",
cloneDay: "Clone day",
@@ -103,16 +121,21 @@ export const en = {
addDose: "Add dose",
removeDose: "Remove dose",
removeDay: "Remove day",
collapseDay: "Collapse day",
expandDay: "Expand day",
dose: "dose",
doses: "doses",
comparedToRegularPlan: "compared to regular plan",
time: "Time",
ldx: "LDX",
damph: "d-amph",
// URL sharing
sharePlan: "Share Plan",
viewingSharedPlan: "You are viewing a shared plan",
viewingSharedPlan: "Viewing shared plan",
saveAsMyPlan: "Save as My Plan",
discardSharedPlan: "Discard",
planCopiedToClipboard: "Plan link copied to clipboard!",
planCopiedToClipboard: "Plan link copied to clipboard!"
};
export default en;

View File

@@ -10,9 +10,9 @@
--card-foreground: 0 0% 10%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 10%;
--primary: 0 0% 15%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 94%;
--primary: 217 91% 60%;
--primary-foreground: 0 0% 100%;
--secondary: 220 15% 88%;
--secondary-foreground: 0 0% 15%;
--muted: 220 10% 95%;
--muted-foreground: 0 0% 45%;