Compare commits

..

10 Commits

12 changed files with 526 additions and 132 deletions

View File

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

View File

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

View File

@@ -14,7 +14,8 @@ import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { FormTimeInput } from './ui/form-time-input'; import { FormTimeInput } from './ui/form-time-input';
import { FormNumericInput } from './ui/form-numeric-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'; import type { DayGroup } from '../constants/defaults';
interface DayScheduleProps { interface DayScheduleProps {
@@ -25,6 +26,7 @@ interface DayScheduleProps {
onAddDose: (dayId: string) => void; onAddDose: (dayId: string) => void;
onRemoveDose: (dayId: string, doseId: string) => void; onRemoveDose: (dayId: string, doseId: string) => void;
onUpdateDose: (dayId: string, doseId: string, field: 'time' | 'ldx' | 'damph', value: string) => void; onUpdateDose: (dayId: string, doseId: string, field: 'time' | 'ldx' | 'damph', value: string) => void;
onSortDoses: (dayId: string) => void;
t: any; t: any;
} }
@@ -36,23 +38,128 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
onAddDose, onAddDose,
onRemoveDose, onRemoveDose,
onUpdateDose, onUpdateDose,
onSortDoses,
t t
}) => { }) => {
const canAddDay = days.length < 3; 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 ( return (
<div className="space-y-4"> <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}> <Card key={day.id}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <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"> <CardTitle className="text-lg">
{day.isTemplate ? t('regularPlan') : t('deviatingPlan')} {day.isTemplate ? t('regularPlan') : t('alternativePlan')}
</CardTitle> </CardTitle>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{t('day')} {dayIndex + 1} {t('day')} {dayIndex + 1}
</Badge> </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>
<div className="flex gap-2"> <div className="flex gap-2">
{canAddDay && ( {canAddDay && (
@@ -79,10 +186,38 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
{!collapsedDays.has(day.id) && (
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{/* Dose table header */} {/* Dose table header */}
<div className="grid grid-cols-[120px_1fr_auto] gap-3 text-sm font-medium text-muted-foreground"> <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>{t('ldx')} (mg)</div>
<div></div> <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 duplicateTimeCount = day.doses.filter(d => d.time === dose.time).length;
const hasDuplicateTime = duplicateTimeCount > 1; const hasDuplicateTime = duplicateTimeCount > 1;
// Check for zero dose
const isZeroDose = dose.ldx === '0' || dose.ldx === '0.0';
return ( return (
<div key={dose.id} className="grid grid-cols-[120px_1fr_auto] gap-3 items-center"> <div key={dose.id} className="grid grid-cols-[120px_1fr_auto] gap-3 items-center">
<FormTimeInput <FormTimeInput
@@ -110,7 +248,9 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
min={0} min={0}
unit="mg" unit="mg"
required={true} required={true}
warning={isZeroDose}
errorMessage={t('errorNumberRequired')} errorMessage={t('errorNumberRequired')}
warningMessage={t('warningZeroDose')}
/> />
<Button <Button
onClick={() => onRemoveDose(day.id, dose.id)} onClick={() => onRemoveDose(day.id, dose.id)}
@@ -139,8 +279,9 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
</Button> </Button>
)} )}
</CardContent> </CardContent>
)}
</Card> </Card>
))} )})}
{/* Add day button */} {/* Add day button */}
{canAddDay && ( {canAddDay && (

View File

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

View File

@@ -46,6 +46,7 @@ const SimulationChart = ({
displayedDays, displayedDays,
yAxisMin, yAxisMin,
yAxisMax, yAxisMax,
days,
t t
}: any) => { }: any) => {
const totalHours = (parseInt(simulationDays, 10) || 3) * 24; const totalHours = (parseInt(simulationDays, 10) || 3) * 24;
@@ -79,6 +80,62 @@ const SimulationChart = ({
return [domainMin, domainMax]; return [domainMin, domainMax];
}, [yAxisMin, yAxisMax]); }, [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 // Merge all profiles into a single dataset for proper tooltip synchronization
const mergedData = React.useMemo(() => { const mergedData = React.useMemo(() => {
const dataMap = new Map(); const dataMap = new Map();
@@ -93,17 +150,23 @@ const SimulationChart = ({
}); });
// Add template profile data (regular plan only) if provided // Add template profile data (regular plan only) if provided
// Only include points for days that have deviations
templateProfile?.forEach((point: any) => { templateProfile?.forEach((point: any) => {
const existing = dataMap.get(point.timeHours) || { timeHours: point.timeHours }; const pointDay = Math.ceil(point.timeHours / 24);
dataMap.set(point.timeHours, {
...existing, // Only include template data for days with deviations
templateDamph: point.damph, if (daysWithDeviations.has(pointDay)) {
templateLdx: point.ldx 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); return Array.from(dataMap.values()).sort((a, b) => a.timeHours - b.timeHours);
}, [combinedProfile, templateProfile]); }, [combinedProfile, templateProfile, daysWithDeviations]);
// Calculate chart dimensions // Calculate chart dimensions
const [containerWidth, setContainerWidth] = React.useState(1000); const [containerWidth, setContainerWidth] = React.useState(1000);
@@ -143,6 +206,11 @@ const SimulationChart = ({
align="left" align="left"
height={36} height={36}
wrapperStyle={{ paddingLeft: 0 }} 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 */} {/* Invisible lines just to show in legend */}
{(chartView === 'damph' || chartView === 'both') && ( {(chartView === 'damph' || chartView === 'both') && (
@@ -166,7 +234,7 @@ const SimulationChart = ({
strokeOpacity={0} strokeOpacity={0}
/> />
)} )}
{templateProfile && (chartView === 'damph' || chartView === 'both') && ( {templateProfile && daysWithDeviations.size > 0 && (chartView === 'damph' || chartView === 'both') && (
<Line <Line
dataKey="templateDamph" dataKey="templateDamph"
name={`${t('dAmphetamine')} (${t('regularPlanOverlay')})`} name={`${t('dAmphetamine')} (${t('regularPlanOverlay')})`}
@@ -175,9 +243,10 @@ const SimulationChart = ({
strokeDasharray="3 3" strokeDasharray="3 3"
dot={false} dot={false}
strokeOpacity={0} strokeOpacity={0}
opacity={0.5}
/> />
)} )}
{templateProfile && (chartView === 'ldx' || chartView === 'both') && ( {templateProfile && daysWithDeviations.size > 0 && (chartView === 'ldx' || chartView === 'both') && (
<Line <Line
dataKey="templateLdx" dataKey="templateLdx"
name={`${t('lisdexamfetamine')} (${t('regularPlanOverlay')})`} name={`${t('lisdexamfetamine')} (${t('regularPlanOverlay')})`}
@@ -186,6 +255,7 @@ const SimulationChart = ({
strokeDasharray="3 3" strokeDasharray="3 3"
dot={false} dot={false}
strokeOpacity={0} strokeOpacity={0}
opacity={0.5}
/> />
)} )}
</LineChart> </LineChart>
@@ -235,7 +305,7 @@ const SimulationChart = ({
}; };
return <XAxis return <XAxis
xAxisId="hours" 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" dataKey="timeHours"
type="number" type="number"
domain={[0, totalHours]} domain={[0, totalHours]}
@@ -248,17 +318,57 @@ const SimulationChart = ({
<YAxis <YAxis
yAxisId="concentration" 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} domain={chartDomain as any}
allowDecimals={false} allowDecimals={false}
tickCount={20} tickCount={20}
/> />
<Tooltip <Tooltip
formatter={(value: any, name) => [`${typeof value === 'number' ? value.toFixed(1) : value} ${t('ngml')}`, name]} content={({ active, payload, label }) => {
labelFormatter={(label, payload) => { if (!active || !payload || payload.length === 0) return null;
// Extract timeHours from the payload data point // Extract timeHours from the payload data point
const timeHours = payload?.[0]?.payload?.timeHours ?? label; const timeHours = payload[0]?.payload?.timeHours ?? label;
return `${t('hour').replace('h', 'Hour')}: ${timeHours}${t('hour')}`; 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 }} wrapperStyle={{ pointerEvents: 'none', zIndex: 200 }}
allowEscapeViewBox={{ x: false, y: false }} allowEscapeViewBox={{ x: false, y: false }}
@@ -267,12 +377,12 @@ const SimulationChart = ({
/> />
<CartesianGrid strokeDasharray="1 1" xAxisId="hours" yAxisId="concentration" /> <CartesianGrid strokeDasharray="1 1" xAxisId="hours" yAxisId="concentration" />
{showDayReferenceLines !== false && [...Array(dispDays).keys()].map(day => ( {showDayReferenceLines !== false && [...Array(dispDays+1).keys()].map(day => (
<ReferenceLine <ReferenceLine
key={`day-${day+1}`} key={`day-${day+1}`}
x={24 * (day+1)} x={24 * (day+1)}
label={{ label={{
value: (day === 0 ? t('refLineRegularPlan') : t('refLineDeviatingPlan')) + ' (' + t('refLineDayX', { x: day+1 }) + ')', value: t('refLineDayX', { x: day+1 }) + '' + getDayLabel(day + 1),
position: 'insideTopRight', position: 'insideTopRight',
style: { style: {
fontSize: '0.75rem', fontSize: '0.75rem',
@@ -351,7 +461,7 @@ const SimulationChart = ({
<Line <Line
type="monotone" type="monotone"
dataKey="templateDamph" dataKey="templateDamph"
name={`${t('dAmphetamine')} (${t('regularPlan')} ${t('continuation')})`} name={`${t('dAmphetamine')} (${t('regularPlanOverlay')})`}
stroke={CHART_COLORS.idealDamph} stroke={CHART_COLORS.idealDamph}
strokeWidth={2} strokeWidth={2}
strokeDasharray="3 3" strokeDasharray="3 3"
@@ -366,7 +476,7 @@ const SimulationChart = ({
<Line <Line
type="monotone" type="monotone"
dataKey="templateLdx" dataKey="templateLdx"
name={`${t('lisdexamfetamine')} (${t('regularPlan')} ${t('continuation')})`} name={`${t('lisdexamfetamine')} (${t('regularPlanOverlay')})`}
stroke={CHART_COLORS.idealLdx} stroke={CHART_COLORS.idealLdx}
strokeWidth={1.5} strokeWidth={1.5}
strokeDasharray="3 3" strokeDasharray="3 3"

View File

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

View File

@@ -14,6 +14,7 @@ import { Button } from "./button"
import { Input } from "./input" import { Input } from "./input"
import { Popover, PopoverContent, PopoverTrigger } from "./popover" import { Popover, PopoverContent, PopoverTrigger } from "./popover"
import { cn } from "../../lib/utils" 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'> {
value: string value: string
@@ -41,13 +42,22 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
className, className,
...props ...props
}, ref) => { }, ref) => {
const { t } = useTranslation()
const [displayValue, setDisplayValue] = React.useState(value) const [displayValue, setDisplayValue] = React.useState(value)
const [isPickerOpen, setIsPickerOpen] = React.useState(false) const [isPickerOpen, setIsPickerOpen] = React.useState(false)
const [showError, setShowError] = React.useState(false) const [, setShowError] = React.useState(false)
const [showWarning, setShowWarning] = 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) const containerRef = React.useRef<HTMLDivElement>(null)
// Current committed value parsed from prop
const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number) 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) // Check if value is invalid (check validity regardless of touch state)
const isInvalid = required && (!value || value.trim() === '') const isInvalid = required && (!value || value.trim() === '')
const hasError = error || isInvalid const hasError = error || isInvalid
@@ -57,9 +67,21 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
setDisplayValue(value) setDisplayValue(value)
}, [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 handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const inputValue = e.target.value.trim() const inputValue = e.target.value.trim()
setTouched(true)
setIsFocused(false)
setShowError(false) setShowError(false)
setShowWarning(false)
if (inputValue === '') { if (inputValue === '') {
// Update parent with empty value so validation works // Update parent with empty value so validation works
@@ -98,21 +120,41 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
} }
const handleFocus = () => { const handleFocus = () => {
setIsFocused(true)
setShowError(hasError) setShowError(hasError)
setShowWarning(hasWarning) setShowWarning(hasWarning)
} }
const handlePickerChange = (part: 'h' | 'm', val: number) => { const handlePickerOpen = (open: boolean) => {
let newHours = pickerHours, newMinutes = pickerMinutes setIsPickerOpen(open)
if (part === 'h') { if (open) {
newHours = val // Reset staging when opening picker
} else { setStagedHour(null)
newMinutes = val 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 = () => { const getAlignmentClass = () => {
switch (align) { switch (align) {
case 'left': return 'text-left' case 'left': return 'text-left'
@@ -142,7 +184,7 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
)} )}
{...props} {...props}
/> />
<Popover open={isPickerOpen} onOpenChange={setIsPickerOpen}> <Popover open={isPickerOpen} onOpenChange={handlePickerOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button" type="button"
@@ -158,59 +200,73 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-3 bg-popover shadow-md border"> <PopoverContent className="w-auto p-3 bg-popover shadow-md border">
<div className="flex gap-2"> <div className="flex flex-col gap-3">
<div className="flex flex-col gap-1"> <div className="flex gap-2">
<div className="text-xs font-medium text-center mb-1">Hour</div> <div className="flex flex-col gap-1">
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto"> <div className="text-xs font-medium text-center mb-1">{t('timePickerHour')}</div>
{Array.from({ length: 24 }, (_, i) => ( <div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto">
<Button {Array.from({ length: 24 }, (_, i) => {
key={i} const isCurrentValue = pickerHours === i && stagedHour === null
type="button" const isStaged = stagedHour === i
variant={pickerHours === i ? "default" : "outline"} return (
size="sm" <Button
className="h-8 w-10" key={i}
onClick={() => { type="button"
handlePickerChange('h', i) variant={isStaged ? "default" : isCurrentValue ? "secondary" : "outline"}
setIsPickerOpen(false) size="sm"
}} className="h-8 w-10"
> onClick={() => handleHourClick(i)}
{String(i).padStart(2, '0')} >
</Button> {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> </div>
<div className="flex flex-col gap-1"> <div className="flex justify-end">
<div className="text-xs font-medium text-center mb-1">Min</div> <Button
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto"> type="button"
{Array.from({ length: 12 }, (_, i) => i * 5).map(minute => ( size="sm"
<Button onClick={handleApply}
key={minute} disabled={!canApply}
type="button" >
variant={pickerMinutes === minute ? "default" : "outline"} {t('timePickerApply')}
size="sm" </Button>
className="h-8 w-10"
onClick={() => {
handlePickerChange('m', minute)
setIsPickerOpen(false)
}}
>
{String(minute).padStart(2, '0')}
</Button>
))}
</div>
</div> </div>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>} {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"> <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} {errorMessage}
</div> </div>
)} )}
{hasWarning && showWarning && warningMessage && ( {hasWarning && isFocused && 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"> <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} {warningMessage}
</div> </div>
)} )}

View File

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

View File

@@ -186,20 +186,11 @@ export const useAppState = () => {
days: prev.days.map(day => { days: prev.days.map(day => {
if (day.id !== dayId) return day; if (day.id !== dayId) return day;
// Update the dose field // Update the dose field (no auto-sort)
const updatedDoses = day.doses.map(dose => const updatedDoses = day.doses.map(dose =>
dose.id === doseId ? { ...dose, [field]: value } : 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 { return {
...day, ...day,
doses: updatedDoses 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 = () => { 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.")) { 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); window.localStorage.removeItem(LOCAL_STORAGE_KEY);
@@ -227,6 +238,7 @@ export const useAppState = () => {
addDoseToDay, addDoseToDay,
removeDoseFromDay, removeDoseFromDay,
updateDoseInDay, updateDoseInDay,
sortDosesInDay,
handleReset handleReset
}; };
}; };

View File

@@ -44,10 +44,14 @@ export const de = {
axisLabelTimeOfDay: "Tageszeit (h)", axisLabelTimeOfDay: "Tageszeit (h)",
tickNoon: "Mittag", tickNoon: "Mittag",
refLineRegularPlan: "Regulärer Plan", refLineRegularPlan: "Regulärer Plan",
refLineDeviatingPlan: "Abweichung", refLineDeviatingPlan: "Abweichung vom Plan",
refLineNoDeviation: "Keine Abweichung",
refLineRecovering: "Erholung",
refLineIrregularIntake: "Irreguläre Einnahme",
refLineDayX: "Tag {{x}}", refLineDayX: "Tag {{x}}",
refLineMin: "Min", refLineMin: "Min",
refLineMax: "Max", refLineMax: "Max",
tooltipHour: "Stunde",
// Settings // Settings
diagramSettings: "Diagramm-Einstellungen", diagramSettings: "Diagramm-Einstellungen",
@@ -58,10 +62,9 @@ export const de = {
xAxisFormat24hDesc: "Wiederholender 0-24h Zyklus", xAxisFormat24hDesc: "Wiederholender 0-24h Zyklus",
xAxisFormat12h: "Tageszeit (12h AM/PM)", xAxisFormat12h: "Tageszeit (12h AM/PM)",
xAxisFormat12hDesc: "Wiederholend 12h Zyklus im AM/PM Format", xAxisFormat12hDesc: "Wiederholend 12h Zyklus im AM/PM Format",
showTemplateDayInChart: "Regulären Plan kontinuierlich im Diagramm anzeigen", showTemplateDayInChart: "Regulären Plan einblenden (nur bei abweichenden Tagen)",
showDayReferenceLines: "Tagestrenner anzeigen", showDayReferenceLines: "Tagestrenner anzeigen (Referenzlinien und Status)",
simulationDuration: "Simulationsdauer", simulationDuration: "Simulationsdauer",
days: "Tage",
displayedDays: "Sichtbare Tage (im Fokus)", displayedDays: "Sichtbare Tage (im Fokus)",
yAxisRange: "Y-Achsen-Bereich (Zoom)", yAxisRange: "Y-Achsen-Bereich (Zoom)",
yAxisRangeAutoButton: "A", yAxisRangeAutoButton: "A",
@@ -70,7 +73,6 @@ export const de = {
therapeuticRange: "Therapeutischer Bereich (Referenzlinien)", therapeuticRange: "Therapeutischer Bereich (Referenzlinien)",
dAmphetamineParameters: "d-Amphetamin Parameter", dAmphetamineParameters: "d-Amphetamin Parameter",
halfLife: "Halbwertszeit", halfLife: "Halbwertszeit",
hours: "h",
lisdexamfetamineParameters: "Lisdexamfetamin Parameter", lisdexamfetamineParameters: "Lisdexamfetamin Parameter",
conversionHalfLife: "Umwandlungs-Halbwertszeit", conversionHalfLife: "Umwandlungs-Halbwertszeit",
absorptionRate: "Absorptionsrate", absorptionRate: "Absorptionsrate",
@@ -78,8 +80,10 @@ export const de = {
resetAllSettings: "Alle Einstellungen zurücksetzen", resetAllSettings: "Alle Einstellungen zurücksetzen",
// Units // Units
mg: "mg", unitMg: "mg",
ngml: "ng/ml", unitNgml: "ng/ml",
unitHour: "h",
unitDays: "Tage",
// Reset confirmation // 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.", 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", 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.", 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 // Field validation
errorNumberRequired: "Bitte gib eine gültige Zahl ein.", errorNumberRequired: "Bitte gib eine gültige Zahl ein.",
errorTimeRequired: "Bitte gib eine gültige Zeitangabe ein.", errorTimeRequired: "Bitte gib eine gültige Zeitangabe ein.",
warningDuplicateTime: "Mehrere Dosen zur gleichen Zeit.", warningDuplicateTime: "Mehrere Dosen zur gleichen Zeit.",
warningZeroDose: "Nulldosis hat keine Auswirkung auf die Simulation.",
// Day-based schedule // Day-based schedule
regularPlan: "Regulärer Plan", regularPlan: "Regulärer Plan",
deviatingPlan: "Abweichung vom Plan", deviatingPlan: "Abweichung vom Plan",
alternativePlan: "Alternativer Plan",
regularPlanOverlay: "Regulär", regularPlanOverlay: "Regulär",
dayNumber: "Tag {{number}}", dayNumber: "Tag {{number}}",
cloneDay: "Tag klonen", cloneDay: "Tag klonen",
@@ -103,6 +112,11 @@ export const de = {
addDose: "Dosis hinzufügen", addDose: "Dosis hinzufügen",
removeDose: "Dosis entfernen", removeDose: "Dosis entfernen",
removeDay: "Tag entfernen", removeDay: "Tag entfernen",
collapseDay: "Tag einklappen",
expandDay: "Tag ausklappen",
dose: "Dosis",
doses: "Dosen",
comparedToRegularPlan: "verglichen mit regulärem Plan",
time: "Zeit", time: "Zeit",
ldx: "LDX", ldx: "LDX",
damph: "d-amph", damph: "d-amph",
@@ -112,7 +126,17 @@ export const de = {
viewingSharedPlan: "Du siehst einen geteilten Plan", viewingSharedPlan: "Du siehst einen geteilten Plan",
saveAsMyPlan: "Als meinen Plan speichern", saveAsMyPlan: "Als meinen Plan speichern",
discardSharedPlan: "Verwerfen", 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; export default de;

View File

@@ -44,7 +44,10 @@ export const en = {
axisLabelTimeOfDay: "Time of Day (h)", axisLabelTimeOfDay: "Time of Day (h)",
tickNoon: "Noon", tickNoon: "Noon",
refLineRegularPlan: "Regular Plan", refLineRegularPlan: "Regular Plan",
refLineDeviatingPlan: "Deviation", refLineDeviatingPlan: "Deviation from Plan",
refLineNoDeviation: "No Deviation",
refLineRecovering: "Recovering",
refLineIrregularIntake: "Irregular Intake",
refLineDayX: "Day {{x}}", refLineDayX: "Day {{x}}",
refLineMin: "Min", refLineMin: "Min",
refLineMax: "Max", refLineMax: "Max",
@@ -58,10 +61,9 @@ export const en = {
xAxisFormat24hDesc: "Repeating 0-24h cycle", xAxisFormat24hDesc: "Repeating 0-24h cycle",
xAxisFormat12h: "Time of Day (12h AM/PM)", xAxisFormat12h: "Time of Day (12h AM/PM)",
xAxisFormat12hDesc: "Repeating 12h cycle in AM/PM format", xAxisFormat12hDesc: "Repeating 12h cycle in AM/PM format",
showTemplateDayInChart: "Overlay regular plan in chart", showTemplateDayInChart: "Show Regular Plan (Only for Deviating Days)",
showDayReferenceLines: "Show day separators", showDayReferenceLines: "Show Day Separators (Reference Lines and Status)",
simulationDuration: "Simulation Duration", simulationDuration: "Simulation Duration",
days: "Days",
displayedDays: "Visible Days (in Focus)", displayedDays: "Visible Days (in Focus)",
yAxisRange: "Y-Axis Range (Zoom)", yAxisRange: "Y-Axis Range (Zoom)",
yAxisRangeAutoButton: "A", yAxisRangeAutoButton: "A",
@@ -70,7 +72,6 @@ export const en = {
therapeuticRange: "Therapeutic Range (Reference Lines)", therapeuticRange: "Therapeutic Range (Reference Lines)",
dAmphetamineParameters: "d-Amphetamine Parameters", dAmphetamineParameters: "d-Amphetamine Parameters",
halfLife: "Half-life", halfLife: "Half-life",
hours: "h",
lisdexamfetamineParameters: "Lisdexamfetamine Parameters", lisdexamfetamineParameters: "Lisdexamfetamine Parameters",
conversionHalfLife: "Conversion Half-life", conversionHalfLife: "Conversion Half-life",
absorptionRate: "Absorption Rate", absorptionRate: "Absorption Rate",
@@ -78,8 +79,10 @@ export const en = {
resetAllSettings: "Reset All Settings", resetAllSettings: "Reset All Settings",
// Units // Units
mg: "mg", unitMg: "mg",
ngml: "ng/ml", unitNgml: "ng/ml",
unitHour: "h",
unitDays: "Days",
// Reset confirmation // Reset confirmation
resetConfirmation: "Are you sure you want to reset all settings to default values? This cannot be undone.", 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", 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.", 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 // Field validation
errorNumberRequired: "Please enter a valid number.", errorNumberRequired: "Please enter a valid number.",
errorTimeRequired: "Please enter a valid time.", errorTimeRequired: "Please enter a valid time.",
warningDuplicateTime: "Multiple doses at same 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 // Day-based schedule
regularPlan: "Regular Plan", regularPlan: "Regular Plan",
deviatingPlan: "Deviation from Plan", deviatingPlan: "Deviation from Plan",
alternativePlan: "Alternative Plan",
regularPlanOverlay: "Regular", regularPlanOverlay: "Regular",
dayNumber: "Day {{number}}", dayNumber: "Day {{number}}",
cloneDay: "Clone day", cloneDay: "Clone day",
@@ -103,16 +121,21 @@ export const en = {
addDose: "Add dose", addDose: "Add dose",
removeDose: "Remove dose", removeDose: "Remove dose",
removeDay: "Remove day", removeDay: "Remove day",
collapseDay: "Collapse day",
expandDay: "Expand day",
dose: "dose",
doses: "doses",
comparedToRegularPlan: "compared to regular plan",
time: "Time", time: "Time",
ldx: "LDX", ldx: "LDX",
damph: "d-amph", damph: "d-amph",
// URL sharing // URL sharing
sharePlan: "Share Plan", sharePlan: "Share Plan",
viewingSharedPlan: "You are viewing a shared plan", viewingSharedPlan: "Viewing shared plan",
saveAsMyPlan: "Save as My Plan", saveAsMyPlan: "Save as My Plan",
discardSharedPlan: "Discard", discardSharedPlan: "Discard",
planCopiedToClipboard: "Plan link copied to clipboard!", planCopiedToClipboard: "Plan link copied to clipboard!"
}; };
export default en; export default en;

View File

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