Update custome translations to i18n and various improvements

This commit is contained in:
2025-12-03 21:53:04 +00:00
parent a54c729e46
commit 6fb6583ae3
16 changed files with 364 additions and 195 deletions

View File

@@ -12,9 +12,12 @@
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.7.1",
"i18next-browser-languagedetector": "^8.2.0",
"lucide-react": "^0.554.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-i18next": "^16.3.5",
"react-is": "^19.2.0",
"react-scripts": "5.0.1",
"recharts": "^3.3.0",

View File

@@ -54,7 +54,8 @@ const MedPlanAssistant = () => {
yAxisMax,
showTemplateDay,
simulationDays,
displayedDays
displayedDays,
showDayReferenceLines
} = uiSettings;
const {
@@ -68,8 +69,8 @@ const MedPlanAssistant = () => {
<header className="mb-8">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">{t.appTitle}</h1>
<p className="text-muted-foreground mt-1">{t.appSubtitle}</p>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">{t('appTitle')}</h1>
<p className="text-muted-foreground mt-1">{t('appSubtitle')}</p>
</div>
<LanguageSelector currentLanguage={currentLanguage} onLanguageChange={changeLanguage} t={t} />
</div>
@@ -84,19 +85,19 @@ const MedPlanAssistant = () => {
onClick={() => updateUiSetting('chartView', 'damph')}
variant={chartView === 'damph' ? 'default' : 'secondary'}
>
{t.dAmphetamine}
{t('dAmphetamine')}
</Button>
<Button
onClick={() => updateUiSetting('chartView', 'ldx')}
variant={chartView === 'ldx' ? 'default' : 'secondary'}
>
{t.lisdexamfetamine}
{t('lisdexamfetamine')}
</Button>
<Button
onClick={() => updateUiSetting('chartView', 'both')}
variant={chartView === 'both' ? 'default' : 'secondary'}
>
{t.both}
{t('both')}
</Button>
</div>
@@ -105,6 +106,7 @@ const MedPlanAssistant = () => {
templateProfile={showTemplateDay ? templateProfile : null}
chartView={chartView}
showDayTimeOnXAxis={showDayTimeOnXAxis}
showDayReferenceLines={showDayReferenceLines}
therapeuticRange={therapeuticRange}
simulationDays={simulationDays}
displayedDays={displayedDays}
@@ -145,8 +147,8 @@ const MedPlanAssistant = () => {
</div>
<footer className="mt-8 p-4 bg-muted rounded-lg text-sm text-muted-foreground border">
<h3 className="font-semibold mb-2 text-foreground">{t.importantNote}</h3>
<p>{t.disclaimer}</p>
<h3 className="font-semibold mb-2 text-foreground">{t('importantNote')}</h3>
<p>{t('disclaimer')}</p>
</footer>
</div>
</div>

View File

@@ -48,13 +48,11 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle className="text-lg">
{day.isTemplate ? t.regularPlan : t.dayNumber.replace('{{number}}', String(dayIndex + 1))}
{day.isTemplate ? t('regularPlan') : t('deviatingPlan')}
</CardTitle>
{day.isTemplate && (
<Badge variant="secondary" className="text-xs">
{t.day} 1
</Badge>
)}
<Badge variant="secondary" className="text-xs">
{t('day')} {dayIndex + 1}
</Badge>
</div>
<div className="flex gap-2">
{canAddDay && (
@@ -62,7 +60,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
onClick={() => onAddDay(day.id)}
size="sm"
variant="outline"
title={t.cloneDay}
title={t('cloneDay')}
>
<Copy className="h-4 w-4" />
</Button>
@@ -73,7 +71,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
size="sm"
variant="outline"
className="border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground"
title={t.removeDay}
title={t('removeDay')}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -84,41 +82,49 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
<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>{t.ldx} (mg)</div>
<div>{t('time')}</div>
<div>{t('ldx')} (mg)</div>
<div></div>
</div>
{/* Dose rows */}
{day.doses.map((dose) => (
<div key={dose.id} className="grid grid-cols-[120px_1fr_auto] gap-3 items-center">
<FormTimeInput
value={dose.time}
onChange={(value) => onUpdateDose(day.id, dose.id, 'time', value)}
required={true}
errorMessage={t.errorTimeRequired}
/>
<FormNumericInput
value={dose.ldx}
onChange={(value) => onUpdateDose(day.id, dose.id, 'ldx', value)}
increment={doseIncrement}
min={0}
unit="mg"
required={true}
errorMessage={t.errorNumberRequired}
/>
<Button
onClick={() => onRemoveDose(day.id, dose.id)}
size="sm"
variant="ghost"
disabled={day.isTemplate && day.doses.length === 1}
className="h-9 w-9 p-0"
title={t.removeDose}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
{day.doses.map((dose) => {
// Check for duplicate times
const duplicateTimeCount = day.doses.filter(d => d.time === dose.time).length;
const hasDuplicateTime = duplicateTimeCount > 1;
return (
<div key={dose.id} className="grid grid-cols-[120px_1fr_auto] gap-3 items-center">
<FormTimeInput
value={dose.time}
onChange={(value) => onUpdateDose(day.id, dose.id, 'time', value)}
required={true}
warning={hasDuplicateTime}
errorMessage={t('errorTimeRequired')}
warningMessage={t('warningDuplicateTime')}
/>
<FormNumericInput
value={dose.ldx}
onChange={(value) => onUpdateDose(day.id, dose.id, 'ldx', value)}
increment={doseIncrement}
min={0}
unit="mg"
required={true}
errorMessage={t('errorNumberRequired')}
/>
<Button
onClick={() => onRemoveDose(day.id, dose.id)}
size="sm"
variant="ghost"
disabled={day.isTemplate && day.doses.length === 1}
className="h-9 w-9 p-0"
title={t('removeDose')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})}
{/* Add dose button */}
{day.doses.length < 5 && (
@@ -129,7 +135,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
className="w-full mt-2"
>
<Plus className="h-4 w-4 mr-2" />
{t.addDose}
{t('addDose')}
</Button>
)}
</CardContent>
@@ -144,7 +150,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
className="w-full"
>
<Plus className="h-4 w-4 mr-2" />
{t.addDay}
{t('addDay')}
</Button>
)}
</div>

View File

@@ -15,14 +15,14 @@ import { Label } from './ui/label';
const LanguageSelector = ({ currentLanguage, onLanguageChange, t }: any) => {
return (
<div className="flex items-center gap-2">
<Label className="text-sm font-medium">{t.language}:</Label>
<Label className="text-sm font-medium">{t('language')}:</Label>
<Select value={currentLanguage} onValueChange={onLanguageChange}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">{t.english}</SelectItem>
<SelectItem value="de">{t.german}</SelectItem>
<SelectItem value="en">{t('english')}</SelectItem>
<SelectItem value="de">{t('german')}</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -16,6 +16,8 @@ import { Button } from './ui/button';
import { Switch } from './ui/switch';
import { Label } from './ui/label';
import { Separator } from './ui/separator';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
const Settings = ({
pkParams,
@@ -28,72 +30,119 @@ const Settings = ({
t
}: any) => {
const { showDayTimeOnXAxis, yAxisMin, yAxisMax, showTemplateDay, simulationDays, displayedDays } = uiSettings;
const showDayReferenceLines = (uiSettings as any).showDayReferenceLines ?? true;
return (
<Card>
<CardHeader>
<CardTitle>{t.advancedSettings}</CardTitle>
<CardTitle className="text-lg">{t('diagramSettings')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<Label htmlFor="showDayTimeOnXAxis" className="font-medium">
{t.show24hTimeAxis}
</Label>
<Switch
id="showDayTimeOnXAxis"
checked={showDayTimeOnXAxis}
onCheckedChange={checked => onUpdateUiSetting('showDayTimeOnXAxis', checked)}
/>
<div className="space-y-2">
<Label className="font-medium">{t('xAxisTimeFormat')}</Label>
<TooltipProvider>
<Select
value={showDayTimeOnXAxis}
onValueChange={value => onUpdateUiSetting('showDayTimeOnXAxis', value)}
>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<Tooltip>
<TooltipTrigger asChild>
<SelectItem value="continuous">
{t('xAxisFormatContinuous')}
</SelectItem>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">{t('xAxisFormatContinuousDesc')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<SelectItem value="24h">
{t('xAxisFormat24h')}
</SelectItem>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">{t('xAxisFormat24hDesc')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<SelectItem value="12h">
{t('xAxisFormat12h')}
</SelectItem>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">{t('xAxisFormat12hDesc')}</p>
</TooltipContent>
</Tooltip>
</SelectContent>
</Select>
</TooltipProvider>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="showTemplateDay" className="font-medium">
{t.showTemplateDayInChart}
<Switch
id="showDayReferenceLines"
checked={showDayReferenceLines}
onCheckedChange={checked => onUpdateUiSetting('showDayReferenceLines', checked)}
/>
<Label htmlFor="showDayReferenceLines" className="font-regular">
{t('showDayReferenceLines')}
</Label>
</div>
<div className="flex items-center gap-3">
<Switch
id="showTemplateDay"
checked={showTemplateDay}
onCheckedChange={checked => onUpdateUiSetting('showTemplateDay', checked)}
/>
<Label htmlFor="showTemplateDay" className="font-regular">
{t('showTemplateDayInChart')}
</Label>
</div>
<div className="space-y-2">
<Label className="font-medium">{t.simulationDuration}</Label>
<Label className="font-medium">{t('simulationDuration')}</Label>
<FormNumericInput
value={simulationDays}
onChange={val => onUpdateUiSetting('simulationDays', val)}
increment={1}
min={3}
max={7}
unit={t.days}
unit={t('days')}
required={true}
errorMessage={t.errorNumberRequired}
errorMessage={t('errorNumberRequired')}
/>
</div>
<div className="space-y-2">
<Label className="font-medium">{t.displayedDays}</Label>
<Label className="font-medium">{t('displayedDays')}</Label>
<FormNumericInput
value={displayedDays}
onChange={val => onUpdateUiSetting('displayedDays', val)}
increment={1}
min={1}
max={parseInt(simulationDays, 10) || 3}
unit={t.days}
unit={t('days')}
required={true}
errorMessage={t.errorNumberRequired}
errorMessage={t('errorNumberRequired')}
/>
</div>
<div className="space-y-2">
<Label className="font-medium">{t.yAxisRange}</Label>
<Label className="font-medium">{t('yAxisRange')}</Label>
<div className="flex items-center gap-2">
<FormNumericInput
value={yAxisMin}
onChange={val => onUpdateUiSetting('yAxisMin', val)}
increment={5}
min={0}
placeholder={t.auto}
placeholder={t('auto')}
allowEmpty={true}
clearButton={true}
/>
@@ -103,7 +152,7 @@ const Settings = ({
onChange={val => onUpdateUiSetting('yAxisMax', val)}
increment={5}
min={0}
placeholder={t.auto}
placeholder={t('auto')}
unit="ng/ml"
allowEmpty={true}
clearButton={true}
@@ -112,16 +161,16 @@ const Settings = ({
</div>
<div className="space-y-2">
<Label className="font-medium">{t.therapeuticRange}</Label>
<Label className="font-medium">{t('therapeuticRange')}</Label>
<div className="flex items-center gap-2">
<FormNumericInput
value={therapeuticRange.min}
onChange={val => onUpdateTherapeuticRange('min', val)}
increment={0.5}
min={0}
placeholder={t.min}
placeholder={t('min')}
required={true}
errorMessage={t.therapeuticRangeMinRequired || 'Minimum therapeutic range is required'}
errorMessage={t('therapeuticRangeMinRequired') || 'Minimum therapeutic range is required'}
/>
<span className="text-muted-foreground">-</span>
<FormNumericInput
@@ -129,19 +178,19 @@ const Settings = ({
onChange={val => onUpdateTherapeuticRange('max', val)}
increment={0.5}
min={0}
placeholder={t.max}
placeholder={t('max')}
unit="ng/ml"
required={true}
errorMessage={t.therapeuticRangeMaxRequired || 'Maximum therapeutic range is required'}
errorMessage={t('therapeuticRangeMaxRequired') || 'Maximum therapeutic range is required'}
/>
</div>
</div>
<Separator className="my-4" />
<h3 className="text-lg font-semibold">{t.dAmphetamineParameters}</h3>
<h3 className="text-lg font-semibold">{t('dAmphetamineParameters')}</h3>
<div className="space-y-2">
<Label className="font-medium">{t.halfLife}</Label>
<Label className="font-medium">{t('halfLife')}</Label>
<FormNumericInput
value={pkParams.damph.halfLife}
onChange={val => onUpdatePkParams('damph', { halfLife: val })}
@@ -149,15 +198,15 @@ const Settings = ({
min={0.1}
unit="h"
required={true}
errorMessage={t.halfLifeRequired || 'Half-life is required'}
errorMessage={t('halfLifeRequired') || 'Half-life is required'}
/>
</div>
<Separator className="my-4" />
<h3 className="text-lg font-semibold">{t.lisdexamfetamineParameters}</h3>
<h3 className="text-lg font-semibold">{t('lisdexamfetamineParameters')}</h3>
<div className="space-y-2">
<Label className="font-medium">{t.conversionHalfLife}</Label>
<Label className="font-medium">{t('conversionHalfLife')}</Label>
<FormNumericInput
value={pkParams.ldx.halfLife}
onChange={val => onUpdatePkParams('ldx', { halfLife: val })}
@@ -165,20 +214,20 @@ const Settings = ({
min={0.1}
unit="h"
required={true}
errorMessage={t.conversionHalfLifeRequired || 'Conversion half-life is required'}
errorMessage={t('conversionHalfLifeRequired') || 'Conversion half-life is required'}
/>
</div>
<div className="space-y-2">
<Label className="font-medium">{t.absorptionRate}</Label>
<Label className="font-medium">{t('absorptionRate')}</Label>
<FormNumericInput
value={pkParams.ldx.absorptionRate}
onChange={val => onUpdatePkParams('ldx', { absorptionRate: val })}
increment={0.1}
min={0.1}
unit={t.faster}
unit={t('faster')}
required={true}
errorMessage={t.absorptionRateRequired || 'Absorption rate is required'}
errorMessage={t('absorptionRateRequired') || 'Absorption rate is required'}
/>
</div>
@@ -190,7 +239,7 @@ const Settings = ({
variant="destructive"
className="w-full"
>
{t.resetAllSettings}
{t('resetAllSettings')}
</Button>
</CardContent>
</Card>

View File

@@ -25,6 +25,8 @@ const CHART_COLORS = {
correctedLdx: '#059669', // emerald-600 (success, dash-dot)
// Reference lines
regularPlanDivider: '#22c55e', // green-500
deviationDayDivider: '#9ca3af', // gray-400
therapeuticMin: '#22c55e', // green-500
therapeuticMax: '#ef4444', // red-500
dayDivider: '#9ca3af', // gray-400
@@ -38,6 +40,7 @@ const SimulationChart = ({
templateProfile,
chartView,
showDayTimeOnXAxis,
showDayReferenceLines,
therapeuticRange,
simulationDays,
displayedDays,
@@ -46,15 +49,27 @@ const SimulationChart = ({
t
}: any) => {
const totalHours = (parseInt(simulationDays, 10) || 3) * 24;
const dispDays = parseInt(displayedDays, 10) || 2;
// Generate ticks for continuous time axis (every 6 hours)
// Dynamically calculate tick interval based on displayed days
// Aim for ~40-50 pixels per tick for readability
const xTickInterval = React.useMemo(() => {
// Scale interval with displayed days: 1 day = 1h, 2 days = 2h, 3-4 days = 3h, 5+ days = 6h
if (dispDays <= 1) return 1;
if (dispDays <= 2) return 2;
if (dispDays <= 4) return 3;
if (dispDays <= 6) return 4;
return 6;
}, [dispDays]);
// Generate ticks for continuous time axis
const chartTicks = React.useMemo(() => {
const ticks = [];
for (let i = 0; i <= totalHours; i += 6) {
for (let i = 0; i <= totalHours; i += xTickInterval) {
ticks.push(i);
}
return ticks;
}, [totalHours]);
}, [totalHours, xTickInterval]);
const chartDomain = React.useMemo(() => {
const numMin = parseFloat(yAxisMin);
@@ -107,7 +122,6 @@ const SimulationChart = ({
}, []);
const simDays = parseInt(simulationDays, 10) || 3;
const dispDays = parseInt(displayedDays, 10) || 2;
// Y-axis takes ~80px, scrollable area gets the rest
const yAxisWidth = 80;
@@ -134,7 +148,7 @@ const SimulationChart = ({
{(chartView === 'damph' || chartView === 'both') && (
<Line
dataKey="combinedDamph"
name={`${t.dAmphetamine}`}
name={`${t('dAmphetamine')}`}
stroke={CHART_COLORS.idealDamph}
strokeWidth={2.5}
dot={false}
@@ -144,7 +158,7 @@ const SimulationChart = ({
{(chartView === 'ldx' || chartView === 'both') && (
<Line
dataKey="combinedLdx"
name={`${t.lisdexamfetamine}`}
name={`${t('lisdexamfetamine')}`}
stroke={CHART_COLORS.idealLdx}
strokeWidth={2}
strokeDasharray="3 3"
@@ -155,7 +169,7 @@ const SimulationChart = ({
{templateProfile && (chartView === 'damph' || chartView === 'both') && (
<Line
dataKey="templateDamph"
name={`${t.dAmphetamine} (${t.regularPlan} ${t.continuation || 'continuation'})`}
name={`${t('dAmphetamine')} (${t('regularPlan')} ${t('continuation')})`}
stroke={CHART_COLORS.idealDamph}
strokeWidth={2}
strokeDasharray="3 3"
@@ -166,7 +180,7 @@ const SimulationChart = ({
{templateProfile && (chartView === 'ldx' || chartView === 'both') && (
<Line
dataKey="templateLdx"
name={`${t.lisdexamfetamine} (${t.regularPlan} ${t.continuation || 'continuation'})`}
name={`${t('lisdexamfetamine')} (${t('regularPlan')} ${t('continuation')})`}
stroke={CHART_COLORS.idealLdx}
strokeWidth={1.5}
strokeDasharray="3 3"
@@ -190,6 +204,8 @@ const SimulationChart = ({
syncId="medPlanChart"
>
<XAxis
xAxisId="hours"
label={{ value: showDayTimeOnXAxis === 'continuous' ? t('axisLabelHours') : t('axisLabelTimeOfDay'), position: 'insideBottom', offset: -10, style: { fontStyle: 'italic', color: '#666' } }}
dataKey="timeHours"
type="number"
domain={[0, totalHours]}
@@ -197,28 +213,35 @@ const SimulationChart = ({
tickCount={chartTicks.length}
interval={0}
tickFormatter={(h) => {
if (showDayTimeOnXAxis) {
if (showDayTimeOnXAxis === '24h') {
// Show 24h repeating format (0-23h)
return `${h % 24}${t.hour}`;
return `${h % 24}${t('hour')}`;
} else if (showDayTimeOnXAxis === '12h') {
// Show 12h AM/PM format
const hour12 = h % 24;
if (hour12 === 12) return t('tickNoon');
const displayHour = hour12 === 0 ? 12 : hour12 > 12 ? hour12 - 12 : hour12;
const period = hour12 < 12 ? 'a' : 'p';
return `${displayHour}${period}`;
} else {
// Show continuous time (0, 6, 12, 18, 24, 30, 36, ...)
return `${h}${t.hour}`;
return `${h}`;
}
}}
xAxisId="hours"
/>
<YAxis
yAxisId="concentration"
//label={{ value: t.concentration, angle: -90, position: 'insideLeft', offset: -10 }}
domain={chartDomain as any}
allowDecimals={false}
/>
yAxisId="concentration"
label={{ value: t('axisLabelConcentration'), angle: -90, position: 'insideLeft', offset: '0 -10', 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]}
formatter={(value: any, name) => [`${typeof value === 'number' ? value.toFixed(1) : value} ${t('ngml')}`, name]}
labelFormatter={(label, payload) => {
// Extract timeHours from the payload data point
const timeHours = payload?.[0]?.payload?.timeHours ?? label;
return `${t.hour.replace('h', 'Hour')}: ${timeHours}${t.hour}`;
return `${t('hour').replace('h', 'Hour')}: ${timeHours}${t('hour')}`;
}}
wrapperStyle={{ pointerEvents: 'none', zIndex: 200 }}
allowEscapeViewBox={{ x: false, y: false }}
@@ -227,11 +250,29 @@ const SimulationChart = ({
/>
<CartesianGrid strokeDasharray="1 1" xAxisId="hours" yAxisId="concentration" />
{showDayReferenceLines !== false && [...Array(dispDays).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 }) + ')',
position: 'insideTopRight',
style: {
fontSize: '0.75rem',
fontStyle: 'italic',
fill: day === 0 ? CHART_COLORS.regularPlanDivider : CHART_COLORS.deviationDayDivider
}
}}
stroke={day === 0 ? CHART_COLORS.regularPlanDivider : CHART_COLORS.deviationDayDivider}
//strokeDasharray="0 0"
xAxisId="hours"
yAxisId="concentration"
/>
))}
{(chartView === 'damph' || chartView === 'both') && (
<ReferenceLine
y={parseFloat(therapeuticRange.min) || 0}
label={{ value: t.min, position: 'insideTopLeft' }}
label={{ value: t('refLineMin'), position: 'insideTopLeft' }}
stroke={CHART_COLORS.therapeuticMin}
strokeDasharray="3 3"
xAxisId="hours"
@@ -241,7 +282,7 @@ const SimulationChart = ({
{(chartView === 'damph' || chartView === 'both') && (
<ReferenceLine
y={parseFloat(therapeuticRange.max) || 0}
label={{ value: t.max, position: 'insideTopLeft' }}
label={{ value: t('refLineMax'), position: 'insideTopLeft' }}
stroke={CHART_COLORS.therapeuticMax}
strokeDasharray="3 3"
xAxisId="hours"
@@ -265,7 +306,7 @@ const SimulationChart = ({
<Line
type="monotone"
dataKey="combinedDamph"
name={`${t.dAmphetamine}`}
name={`${t('dAmphetamine')}`}
stroke={CHART_COLORS.idealDamph}
strokeWidth={2.5}
dot={false}
@@ -278,7 +319,7 @@ const SimulationChart = ({
<Line
type="monotone"
dataKey="combinedLdx"
name={`${t.lisdexamfetamine}`}
name={`${t('lisdexamfetamine')}`}
stroke={CHART_COLORS.idealLdx}
strokeWidth={2}
dot={false}
@@ -293,7 +334,7 @@ const SimulationChart = ({
<Line
type="monotone"
dataKey="templateDamph"
name={`${t.dAmphetamine} (${t.regularPlan} ${t.continuation || 'continuation'})`}
name={`${t('dAmphetamine')} (${t('regularPlan')} ${t('continuation')})`}
stroke={CHART_COLORS.idealDamph}
strokeWidth={2}
strokeDasharray="3 3"
@@ -308,7 +349,7 @@ const SimulationChart = ({
<Line
type="monotone"
dataKey="templateLdx"
name={`${t.lisdexamfetamine} (${t.regularPlan} ${t.continuation || 'continuation'})`}
name={`${t('lisdexamfetamine')} (${t('regularPlan')} ${t('continuation')})`}
stroke={CHART_COLORS.idealLdx}
strokeWidth={1.5}
strokeDasharray="3 3"

View File

@@ -25,8 +25,10 @@ interface NumericInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
allowEmpty?: boolean
clearButton?: boolean
error?: boolean
warning?: boolean
required?: boolean
errorMessage?: string
warningMessage?: string
}
const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
@@ -41,18 +43,22 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
allowEmpty = false,
clearButton = false,
error = false,
warning = false,
required = false,
errorMessage = 'This field is required',
errorMessage = 'Time is required',
warningMessage,
className,
...props
}, ref) => {
const [showError, setShowError] = React.useState(false)
const [showWarning, setShowWarning] = React.useState(false)
const [touched, setTouched] = React.useState(false)
const containerRef = React.useRef<HTMLDivElement>(null)
// Check if value is invalid (check validity regardless of touch state)
const isInvalid = required && !allowEmpty && (value === '' || value === null || value === undefined)
const hasError = error || isInvalid
const hasWarning = warning && !hasError
// Check validity on mount and when value changes
React.useEffect(() => {
@@ -123,6 +129,7 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
const handleFocus = () => {
setShowError(hasError)
setShowWarning(hasWarning)
}
const getAlignmentClass = () => {
@@ -197,11 +204,16 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
)}
</div>
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
{hasError && showError && (
{hasError && showError && 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">
{warningMessage}
</div>
)}
</div>
)
}

View File

@@ -21,8 +21,10 @@ interface TimeInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement
unit?: string
align?: 'left' | 'center' | 'right'
error?: boolean
warning?: boolean
required?: boolean
errorMessage?: string
warningMessage?: string
}
const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
@@ -32,20 +34,24 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
unit,
align = 'center',
error = false,
warning = false,
required = false,
errorMessage = 'Time is required',
warningMessage,
className,
...props
}, ref) => {
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 containerRef = React.useRef<HTMLDivElement>(null)
const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number)
// Check if value is invalid (check validity regardless of touch state)
const isInvalid = required && (!value || value.trim() === '')
const hasError = error || isInvalid
const hasWarning = warning && !hasError
React.useEffect(() => {
setDisplayValue(value)
@@ -93,6 +99,7 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
const handleFocus = () => {
setShowError(hasError)
setShowWarning(hasWarning)
}
const handlePickerChange = (part: 'h' | 'm', val: number) => {
@@ -130,7 +137,8 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
"w-20 h-9 z-20",
"rounded-r-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}
/>
@@ -196,11 +204,16 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
</Popover>
</div>
{unit && <span className="text-sm text-muted-foreground whitespace-nowrap">{unit}</span>}
{hasError && showError && (
{hasError && showError && 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">
{warningMessage}
</div>
)}
</div>
)
}

View File

@@ -40,13 +40,14 @@ export interface TherapeuticRange {
}
export interface UiSettings {
showDayTimeOnXAxis: boolean;
showDayTimeOnXAxis: 'continuous' | '24h' | '12h';
showTemplateDay: boolean;
chartView: 'ldx' | 'damph' | 'both';
yAxisMin: string;
yAxisMax: string;
simulationDays: string;
displayedDays: string;
showDayReferenceLines?: boolean;
}
export interface AppState {
@@ -98,7 +99,7 @@ export const getDefaultState = (): AppState => ({
therapeuticRange: { min: '10.5', max: '11.5' },
doseIncrement: '2.5',
uiSettings: {
showDayTimeOnXAxis: true,
showDayTimeOnXAxis: 'continuous',
showTemplateDay: false,
chartView: 'both',
yAxisMin: '0',

View File

@@ -22,12 +22,19 @@ export const useAppState = () => {
if (savedState) {
const parsedState = JSON.parse(savedState);
const defaults = getDefaultState();
// Migrate old boolean showDayTimeOnXAxis to new string enum
let migratedUiSettings = {...defaults.uiSettings, ...parsedState.uiSettings};
if (typeof migratedUiSettings.showDayTimeOnXAxis === 'boolean') {
migratedUiSettings.showDayTimeOnXAxis = migratedUiSettings.showDayTimeOnXAxis ? '24h' : 'continuous';
}
setAppState({
...defaults,
...parsedState,
pkParams: {...defaults.pkParams, ...parsedState.pkParams},
days: parsedState.days || defaults.days,
uiSettings: {...defaults.uiSettings, ...parsedState.uiSettings},
uiSettings: migratedUiSettings,
});
}
} catch (error) {
@@ -178,11 +185,24 @@ export const useAppState = () => {
...prev,
days: prev.days.map(day => {
if (day.id !== dayId) return day;
// Update the dose field
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: day.doses.map(dose =>
dose.id === doseId ? { ...dose, [field]: value } : dose
)
doses: updatedDoses
};
})
}));

View File

@@ -1,34 +1,25 @@
/**
* Language Hook
* Language Hook using react-i18next
*
* Manages application language state and provides translation access.
* Persists language preference to localStorage.
* Provides internationalization with substitution capabilities using react-i18next.
*
* @author Andreas Weyer
* @license MIT
*/
import React from 'react';
import { translations, getInitialLanguage } from '../locales/index';
import { useTranslation } from 'react-i18next';
export const useLanguage = () => {
const [currentLanguage, setCurrentLanguage] = React.useState(getInitialLanguage);
const { t, i18n } = useTranslation();
// Get current translations
const t = translations[currentLanguage as keyof typeof translations] || translations.en;
// Change language and save to localStorage
const changeLanguage = (lang: string) => {
if (translations[lang as keyof typeof translations]) {
setCurrentLanguage(lang);
localStorage.setItem('medPlanAssistant_language', lang);
}
i18n.changeLanguage(lang);
};
return {
currentLanguage,
currentLanguage: i18n.language,
changeLanguage,
t,
availableLanguages: Object.keys(translations)
availableLanguages: Object.keys(i18n.services.resourceStore.data),
};
};

36
src/i18n.ts Normal file
View File

@@ -0,0 +1,36 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './locales/en';
import de from './locales/de';
const resources = {
en: {
translation: en,
},
de: {
translation: de,
},
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
debug: process.env.NODE_ENV === 'development',
interpolation: {
escapeValue: false, // React already escapes values
},
detection: {
order: ['localStorage', 'navigator', 'htmlTag'],
caches: ['localStorage'],
lookupLocalStorage: 'medPlanAssistant_language',
},
});
export default i18n;

View File

@@ -1,6 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './styles/global.css';
import './i18n'; // Initialize i18n
import App from './App';
const rootElement = document.getElementById('root');

View File

@@ -39,22 +39,35 @@ export const de = {
noSuitableNextDose: "Keine passende nächste Dosis für Korrektur gefunden.",
// Chart
concentration: "Konzentration (ng/ml)",
hour: "h",
min: "Min",
max: "Max",
axisLabelConcentration: "Konzentration (ng/ml)",
axisLabelHours: "Stunden (h)",
axisLabelTimeOfDay: "Tageszeit (h)",
tickNoon: "Mittag",
refLineRegularPlan: "Regulärer Plan",
refLineDeviatingPlan: "Abweichung",
refLineDayX: "Tag {{x}}",
refLineMin: "Min",
refLineMax: "Max",
// Settings
advancedSettings: "Erweiterte Einstellungen",
show24hTimeAxis: "24h-Zeitachse anzeigen",
diagramSettings: "Diagramm-Einstellungen",
xAxisTimeFormat: "Zeitformat",
xAxisFormatContinuous: "Fortlaufend",
xAxisFormatContinuousDesc: "Endlose Sequenz (0h, 6h, 12h...)",
xAxisFormat24h: "24h",
xAxisFormat24hDesc: "Wiederholender 0-24h Zyklus",
xAxisFormat12h: "12h AM/PM",
xAxisFormat12hDesc: "Wiederholend 12h mit AM/PM",
showTemplateDayInChart: "Regulären Plan kontinuierlich im Diagramm anzeigen",
showDayReferenceLines: "Tagestrenner anzeigen",
simulationDuration: "Simulationsdauer",
days: "Tage",
displayedDays: "Angezeigte Tage",
yAxisRange: "Y-Achsen-Bereich",
displayedDays: "Sichtbare Tage (im Fokus)",
yAxisRange: "Y-Achsen-Bereich (Zoom)",
yAxisRangeAutoButton: "A",
yAxisRangeAutoButtonTitle: "Bereich automatisch anhand des Datenbereichs bestimmen",
auto: "Auto",
therapeuticRange: "Therapeutischer Bereich",
therapeuticRange: "Therapeutischer Bereich (Referenzlinien)",
dAmphetamineParameters: "d-Amphetamin Parameter",
halfLife: "Halbwertszeit",
hours: "h",
@@ -78,9 +91,11 @@ export const de = {
// Field validation
errorNumberRequired: "Bitte gib eine gültige Zahl ein.",
errorTimeRequired: "Bitte gib eine gültige Zeitangabe ein.",
warningDuplicateTime: "Mehrere Dosen zur gleichen Zeit.",
// Day-based schedule
regularPlan: "Regulärer Plan",
deviatingPlan: "Abweichender Plan",
continuation: "Fortsetzung",
dayNumber: "Tag {{number}}",
cloneDay: "Tag klonen",
@@ -97,8 +112,7 @@ export const de = {
viewingSharedPlan: "Du siehst einen geteilten Plan",
saveAsMyPlan: "Als meinen Plan speichern",
discardSharedPlan: "Verwerfen",
planCopiedToClipboard: "Plan-Link in Zwischenablage kopiert!",
showTemplateDayInChart: "Regulären Plan im Diagramm anzeigen"
planCopiedToClipboard: "Plan-Link in Zwischenablage kopiert!"
};
export default de;

View File

@@ -39,22 +39,35 @@ export const en = {
noSuitableNextDose: "No suitable next dose found for correction.",
// Chart
concentration: "Concentration (ng/ml)",
hour: "h",
min: "Min",
max: "Max",
axisLabelConcentration: "Concentration (ng/ml)",
axisLabelHours: "Hours (h)",
axisLabelTimeOfDay: "Time of Day (h)",
tickNoon: "Noon",
refLineRegularPlan: "Regular Plan",
refLineDeviatingPlan: "Deviation",
refLineDayX: "Day {{x}}",
refLineMin: "Min",
refLineMax: "Max",
// Settings
advancedSettings: "Advanced Settings",
show24hTimeAxis: "Show 24h time axis",
diagramSettings: "Diagram Settings",
xAxisTimeFormat: "Time Format",
xAxisFormatContinuous: "Continuous",
xAxisFormatContinuousDesc: "Endless sequence (0h, 6h, 12h...)",
xAxisFormat24h: "24h",
xAxisFormat24hDesc: "Repeating 0-24h cycle",
xAxisFormat12h: "12h AM/PM",
xAxisFormat12hDesc: "Repeating 12h with AM/PM",
showTemplateDayInChart: "Overlay regular plan in chart",
showDayReferenceLines: "Show day separators",
simulationDuration: "Simulation Duration",
days: "Days",
displayedDays: "Displayed Days",
yAxisRange: "Y-Axis Range",
displayedDays: "Visible Days (in Focus)",
yAxisRange: "Y-Axis Range (Zoom)",
yAxisRangeAutoButton: "A",
yAxisRangeAutoButtonTitle: "Determine range automatically based on data range",
auto: "Auto",
therapeuticRange: "Therapeutic Range",
therapeuticRange: "Therapeutic Range (Reference Lines)",
dAmphetamineParameters: "d-Amphetamine Parameters",
halfLife: "Half-life",
hours: "h",
@@ -78,9 +91,11 @@ export const en = {
// Field validation
errorNumberRequired: "Please enter a valid number.",
errorTimeRequired: "Please enter a valid time.",
warningDuplicateTime: "Multiple doses at same time.",
// Day-based schedule
regularPlan: "Regular Plan",
deviatingPlan: "Deviating Plan",
continuation: "continuation",
dayNumber: "Day {{number}}",
cloneDay: "Clone day",
@@ -98,7 +113,6 @@ export const en = {
saveAsMyPlan: "Save as My Plan",
discardSharedPlan: "Discard",
planCopiedToClipboard: "Plan link copied to clipboard!",
showTemplateDayInChart: "Show regular plan in chart"
};
export default en;

View File

@@ -1,34 +0,0 @@
/**
* Internationalization (i18n) Configuration
*
* Manages application translations and language detection.
* Supports English and German with browser language preference detection.
*
* @author Andreas Weyer
* @license MIT
*/
import en from './en';
import de from './de';
export const translations = {
en,
de
};
// Get browser language preference
export const getBrowserLanguage = () => {
const browserLang = navigator.language;
return browserLang.startsWith('de') ? 'de' : 'en';
};
// Get initial language from localStorage or browser preference
export const getInitialLanguage = () => {
const stored = localStorage.getItem('medPlanAssistant_language');
if (stored && translations[stored as keyof typeof translations]) {
return stored;
}
return getBrowserLanguage();
};
export default translations;