Compare commits

...

32 Commits

Author SHA1 Message Date
5f64372b94 Update profile selector, new rename functionality, display options in alphabetical order 2026-02-18 11:19:58 +00:00
89c26fb20c Update moved profile selector to it's original location 2026-02-18 11:18:07 +00:00
48b2ead287 Fix simulation-chart missing day separator lines 2026-02-16 16:26:51 +00:00
a41189bff2 Update chart tick interval/distance, minor text changes 2026-02-16 16:14:02 +00:00
a5bb698250 Update app moved schedule seletion/save card from left to right column 2026-02-11 13:21:11 +00:00
2cd001644e Fix y-axis tick labels show floating point numbers 2026-02-11 13:20:10 +00:00
fbba3d6122 Update therapeutic range min/max values no longer mandatory 2026-02-10 19:52:25 +00:00
a1298d64a7 Fix invalid size error in chart causing crashes due to temporary invalid dimensions during mount/update 2026-02-10 19:41:01 +00:00
955d3ad650 Fix negative intake time delta while in time picker (shown with "+-" prefix), improved time picker layout 2026-02-10 19:25:22 +00:00
cafc0a266d Fix FormNumericInput incorrect border highlighting (warn/error) and default error message text 2026-02-10 18:39:03 +00:00
b198164760 Add profile management functionality
- Added profile management functions: createProfile, deleteProfile, switchProfile, saveProfile, saveProfileAs, updateProfileName, and hasUnsavedChanges.
- Migrated state management to support profile-based format for schedules.
- Updated localizations for profile management features in English and German.
- Introduced ProfileSelector component for user interface to manage profiles.
- Enhanced export/import functionality to handle profiles and schedules.
2026-02-10 18:33:41 +00:00
3b4db14424 Fix chart performance issues and duplicate keys
- Memoize XAxisTick and YAxisTick renderers with useCallback
- Remove Y-axis tickCount and allowDecimals=false to prevent duplicate keys
- Add React.memo to SimulationChart with custom comparison
- Remove unnecessary sorting after isFed and remove dose actions
- Add handleActionWithoutSort for actions that don't affect order
- Prevents double state updates that caused 'every other click' freezes
2026-02-09 19:58:15 +00:00
d544c7f3b3 Update day-schedule layout (style improvements/fixes), attached delta badge to time input field 2026-02-09 19:37:30 +00:00
8325f10b19 Fix performance issue: debounce resize event listeners
- Add useDebounce hook for value debouncing
- Add useWindowSize hook for debounced window dimensions
- Add useElementSize hook for debounced element size tracking with ResizeObserver
- Replace undebounced resize listeners in App.tsx, simulation-chart.tsx, and settings.tsx
- Prevents excessive re-renders during window resize operations
- Resolves app freezing and performance degradation
2026-02-09 17:19:43 +00:00
7a2a8b0b47 Add intake auto sorting, chart intake markers, upped max daily intakes to 6, various style changes 2026-02-09 17:08:53 +00:00
c41db99cba Update new check for max daily dose waring and error 2026-02-08 12:08:18 +00:00
7f8503387c Update various color/style improvements, primarily for error/warn/info bubbles and boxes 2026-02-07 20:06:56 +00:00
651097b3fb Add contentFormatter for tooltips and error/warning bubbles, format i18n texts, add missing high-dose warning 2026-02-07 15:45:42 +00:00
ed79247223 Update minor tooltip text addition 2026-02-07 12:39:40 +00:00
c5502085e8 Fix AI research doc formatting, latex math and citations 2026-02-07 12:38:27 +00:00
765f7d6d35 Add form-select.tsx with reset to default button used in settings 2026-02-07 10:47:36 +00:00
f76cb81108 Update number inupt max/min value now disables +/- buttons respectively 2026-02-07 10:27:11 +00:00
383fd928d1 Update settings reset to default buttons for all number fields 2026-02-07 10:18:29 +00:00
199872d742 Update combined static Vd and weight-based settings 2026-02-07 10:12:06 +00:00
b7a585a223 Update dark mode improvements for chart 2026-02-04 15:18:58 +00:00
efa45ab288 Update data deletion now in data manager with customization, minor UI improvements, increased chart y-axis tick count (regression) 2026-02-04 12:24:03 +00:00
11dacb5441 Fix number input floating point issue when pressing plus/minus buttons 2026-02-02 18:04:14 +00:00
2c55652f92 Update fixed consistent combobox width 2026-02-02 18:03:35 +00:00
f4260061f5 Update various improvements and minor changes 2026-02-02 17:35:11 +00:00
02b1209c2d Update settings add min<=max validation to ranges, minor text changes 2026-02-02 13:17:35 +00:00
90b0806cec Fix isFed state for regular plan comparison line, simplified urin ph selection 2026-02-02 11:51:39 +00:00
8e74fe576f Fix minor dark mode issues and language/theme selection alignement 2026-02-02 11:21:20 +00:00
27 changed files with 3985 additions and 1244 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,129 @@
# Content Formatting Usage Guide
The `contentFormatter` utility (`src/utils/contentFormatter.tsx`) provides markdown-style formatting for various UI content throughout the application.
## Supported Formatting
- **Bold:** `**text**`**text**
- **Italic:** `*text*`*text*
- **Bold + Italic:** `***text***`***text***
- **Underline:** `__text__` → <u>text</u>
- **Line breaks:** `\n` (use `\\n` in translation strings)
- **Links:** `[text](url)` → clickable link with yellow underline
## Current Usage
### 1. Tooltips (✅ Already Implemented)
All tooltips in `settings.tsx` use `formatContent()`:
```tsx
import { formatContent } from '../utils/contentFormatter';
<TooltipContent>
<p className="text-xs max-w-xs">
{formatContent(t('myTooltip'))}
</p>
</TooltipContent>
```
**Example translation:**
```typescript
myTooltip: "This is a tooltip.\\n\\n**Important:** Some key info.\\n\\n***Default:*** 11h."
```
## Potential Future Usage
### 2. Error/Warning Messages in Form Fields
The formatter can be applied to `errorMessage` and `warningMessage` props in form components:
**Current implementation** (plain text):
```tsx
<FormNumericInput
errorMessage="Value must be between 5 and 50"
warningMessage="Value is outside typical range"
/>
```
**With formatting** (enhanced):
```tsx
import { formatContent } from '../utils/contentFormatter';
// In FormNumericInput component (form-numeric-input.tsx):
{hasError && isFocused && errorMessage && (
<div className="absolute top-full left-0 w-full mt-1 p-2 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-700 rounded text-xs text-red-800 dark:text-red-200 z-50">
{formatContent(errorMessage)}
</div>
)}
```
**Example with formatting:**
```typescript
errorMessage={t('errorEliminationHalfLife')}
// In translations:
errorEliminationHalfLife: "**Invalid value.**\\n\\nHalf-life must be between **5h** and **50h**.\\n\\nSee [reference ranges](https://example.com)."
```
### 3. Info Boxes
Static info boxes (like `advancedSettingsWarning`) could support formatting:
**Current:**
```tsx
<p className="text-yellow-800 dark:text-yellow-200">
{t('advancedSettingsWarning')}
</p>
```
**With formatting:**
```tsx
<div className="text-yellow-800 dark:text-yellow-200">
{formatContent(t('advancedSettingsWarning'))}
</div>
```
**Example translation:**
```typescript
advancedSettingsWarning: "⚠️ **Warning:**\\n\\nThese parameters affect simulation accuracy.\\n\\nOnly adjust if you have ***specific clinical data*** or research references."
```
### 4. Modal Content
Dialog/modal descriptions could use formatting for better readability:
```tsx
<DialogDescription>
{formatContent(t('deleteConfirmation'))}
</DialogDescription>
// Translation:
deleteConfirmation: "Are you sure you want to delete this data?\\n\\n**This action cannot be undone.**\\n\\nConsider [exporting a backup](export) first."
```
## Implementation Checklist
To add formatting support to a component:
1. ✅ Import the formatter: `import { formatContent } from '../utils/contentFormatter'`
2. ✅ Wrap the content: `{formatContent(text)}`
3. ✅ Update translations to use `\\n`, `**bold**`, `*italic*`, etc.
4. ✅ Test in both light and dark themes
5. ✅ Ensure links open in new tabs (already handled by formatter)
## Notes
- The formatter returns React nodes, so it should replace the content, not be nested inside `{}`
- Links automatically get `target="_blank"` and `rel="noopener noreferrer"`
- Link color is yellow (`text-yellow-300`) to maintain visibility in dark themes
- Line breaks use `\\n` in translation files (double backslash for escaping)
- The formatter is safe for user-generated content (doesn't execute scripts)
## Benefits
- **Improved readability:** Structure complex information with line breaks and emphasis
- **Consistency:** Unified formatting across tooltips, errors, warnings, and info boxes
- **Accessibility:** Links and emphasis improve screen reader experience
- **Maintainability:** Simple markdown-style syntax in translation files
- **I18n friendly:** All formatting stays in translation strings, easy to translate

View File

@@ -0,0 +1,161 @@
# Custom CSS Utility Classes
This document describes the centralized CSS utility classes defined in `src/styles/global.css` for consistent styling across the application.
## Error & Warning Classes
### Validation Bubbles (Popups)
**`.error-bubble`**
- Used for error validation popup messages on form fields
- Light mode: Soft red background with dark red text
- Dark mode: Very dark red background (80% opacity) with light red text
- Includes border for visual separation
- Example: Input field validation errors
**`.warning-bubble`**
- Used for warning validation popup messages on form fields
- Light mode: Soft amber background with dark amber text
- Dark mode: Very dark amber background (80% opacity) with light amber text
- Includes border for visual separation
- Example: Input field warnings about unusual values
### Borders
**`.error-border`**
- Red border for form inputs with errors
- Uses the `destructive` color from the theme
- Example: Highlight invalid input fields
**`.warning-border`**
- Amber border for form inputs with warnings
- Uses `amber-500` color
- Example: Highlight input fields with unusual but valid values
### Background Boxes (Static Sections)
**`.error-bg-box`**
- For static error information sections
- Light mode: Light red background
- Dark mode: Dark red background (40% opacity)
- Includes border
- Example: Persistent error messages in modals
**`.warning-bg-box`**
- For static warning information sections
- Light mode: Light amber background
- Dark mode: Dark amber background (40% opacity)
- Includes border
- Example: Warning boxes in settings
**`.info-bg-box`**
- For informational sections
- Light mode: Light blue background
- Dark mode: Dark blue background (40% opacity)
- Includes border
- Example: Helpful tips, contextual information
### Text Colors
**`.error-text`**
- Dark red text in light mode, light red in dark mode
- High contrast for readability
- Example: Inline error messages
**`.warning-text`**
- Dark amber text in light mode, light amber in dark mode
- High contrast for readability
- Example: Inline warning messages
**`.info-text`**
- Dark blue text in light mode, light blue in dark mode
- High contrast for readability
- Example: Inline informational text
## Usage Examples
### Form Validation Popup
```tsx
{hasError && (
<div className="error-bubble w-80 text-xs p-2 rounded-md">
{errorMessage}
</div>
)}
{hasWarning && (
<div className="warning-bubble w-80 text-xs p-2 rounded-md">
{warningMessage}
</div>
)}
```
### Input Field Borders
```tsx
<Input
className={cn(
hasError && "error-border",
hasWarning && !hasError && "warning-border"
)}
/>
```
### Static Information Boxes
```tsx
{/* Warning box */}
<div className="warning-bg-box rounded-md p-3">
<p className="warning-text">{warningText}</p>
</div>
{/* Info box */}
<div className="info-bg-box rounded-md p-3">
<p className="info-text">{infoText}</p>
</div>
{/* Error box */}
<div className="error-bg-box rounded-md p-3">
<p className="error-text">{errorText}</p>
</div>
```
## Accessibility
All classes are designed with accessibility in mind:
-**High contrast ratios** - Meet WCAG AA standards for text readability
-**Dark mode optimized** - Reduced saturation and brightness in dark mode (80% opacity for bubbles, 40% for boxes)
-**Consistent theming** - Semantic color usage (red=error, amber=warning, blue=info)
-**Icon visibility** - Muted backgrounds ensure icons stand out
-**Border separation** - Clear visual boundaries between elements
## Opacity Rationale
- **Validation bubbles**: 80% opacity in dark mode - Higher opacity for better text readability during focused interaction
- **Background boxes**: 40% opacity in dark mode - Lower opacity for persistent elements to reduce visual weight
## Migration Guide
When updating existing code to use these classes:
**Before:**
```tsx
className="bg-red-50 dark:bg-red-950/50 text-red-900 dark:text-red-200 border border-red-300 dark:border-red-800"
```
**After:**
```tsx
className="error-bubble"
```
This reduces duplication, ensures consistency, and makes it easier to update the design system in the future.
## Files Using These Classes
- `src/components/ui/form-numeric-input.tsx`
- `src/components/ui/form-time-input.tsx`
- `src/components/settings.tsx`
- `src/components/data-management-modal.tsx`
- `src/components/disclaimer-modal.tsx`
- `src/components/day-schedule.tsx`

View File

@@ -20,15 +20,19 @@ import LanguageSelector from './components/language-selector';
import ThemeSelector from './components/theme-selector'; import ThemeSelector from './components/theme-selector';
import DisclaimerModal from './components/disclaimer-modal'; import DisclaimerModal from './components/disclaimer-modal';
import DataManagementModal from './components/data-management-modal'; import DataManagementModal from './components/data-management-modal';
import { ProfileSelector } from './components/profile-selector';
import { Button } from './components/ui/button'; import { Button } from './components/ui/button';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from './components/ui/tooltip'; import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from './components/ui/tooltip';
import { IconButtonWithTooltip } from './components/ui/icon-button-with-tooltip'; import { IconButtonWithTooltip } from './components/ui/icon-button-with-tooltip';
import { PROJECT_REPOSITORY_URL, APP_VERSION } from './constants/defaults'; import { PROJECT_REPOSITORY_URL, APP_VERSION } from './constants/defaults';
import { deleteSelectedData } from './utils/exportImport';
import type { ExportOptions } from './utils/exportImport';
// Custom Hooks // Custom Hooks
import { useAppState } from './hooks/useAppState'; import { useAppState } from './hooks/useAppState';
import { useSimulation } from './hooks/useSimulation'; import { useSimulation } from './hooks/useSimulation';
import { useLanguage } from './hooks/useLanguage'; import { useLanguage } from './hooks/useLanguage';
import { useWindowSize } from './hooks/useWindowSize';
// --- Main Component --- // --- Main Component ---
const MedPlanAssistant = () => { const MedPlanAssistant = () => {
@@ -57,36 +61,38 @@ const MedPlanAssistant = () => {
}; };
// Use shorter button labels on narrow screens to keep the pin control visible // Use shorter button labels on narrow screens to keep the pin control visible
const [useCompactButtons, setUseCompactButtons] = React.useState(false); // Using debounced window size to prevent performance issues during resize
const { width: windowWidth } = useWindowSize(150);
React.useEffect(() => { const useCompactButtons = windowWidth < 520; // tweakable threshold
const updateCompact = () => {
setUseCompactButtons(window.innerWidth < 520); // tweakable threshold
};
updateCompact();
window.addEventListener('resize', updateCompact);
return () => window.removeEventListener('resize', updateCompact);
}, []);
const { const {
appState, appState,
updateState, updateState,
updateNestedState, updateNestedState,
updateUiSetting, updateUiSetting,
handleReset,
addDay, addDay,
removeDay, removeDay,
addDoseToDay, addDoseToDay,
removeDoseFromDay, removeDoseFromDay,
updateDoseInDay, updateDoseInDay,
updateDoseFieldInDay, updateDoseFieldInDay,
sortDosesInDay sortDosesInDay,
// Profile management
getActiveProfile,
createProfile,
deleteProfile,
switchProfile,
saveProfile,
saveProfileAs,
updateProfileName,
hasUnsavedChanges
} = useAppState(); } = useAppState();
const { const {
pkParams, pkParams,
days, days,
profiles,
activeProfileId,
therapeuticRange, therapeuticRange,
doseIncrement, doseIncrement,
uiSettings uiSettings
@@ -129,21 +135,51 @@ const MedPlanAssistant = () => {
displayedDays, displayedDays,
showDayReferenceLines showDayReferenceLines
} = uiSettings; } = uiSettings;
const showIntakeTimeLines = (uiSettings as any).showIntakeTimeLines ?? false;
const { const {
combinedProfile, combinedProfile,
templateProfile templateProfile
} = useSimulation(appState); } = useSimulation(appState);
// Handle data deletion
const handleDeleteData = (options: ExportOptions) => {
const newState = deleteSelectedData(appState, options);
// Apply all state updates
Object.entries(newState).forEach(([key, value]) => {
if (key === 'days') {
updateState('days', value as any);
} else if (key === 'profiles') {
updateState('profiles', value as any);
} else if (key === 'activeProfileId') {
updateState('activeProfileId', value as any);
} else if (key === 'pkParams') {
updateState('pkParams', value as any);
} else if (key === 'therapeuticRange') {
updateState('therapeuticRange', value as any);
} else if (key === 'doseIncrement') {
updateState('doseIncrement', value as any);
} else if (key === 'uiSettings') {
// Update UI settings individually
Object.entries(value as any).forEach(([uiKey, uiValue]) => {
updateUiSetting(uiKey as any, uiValue);
});
}
});
};
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="min-h-screen bg-background p-4 sm:p-6 lg:p-8"> <div className="min-h-screen bg-background p-4">{/* sm:p-6 lg:p-8 */}
{/* Disclaimer Modal */} {/* Disclaimer Modal */}
<DisclaimerModal <DisclaimerModal
isOpen={showDisclaimer} isOpen={showDisclaimer}
onAccept={handleAcceptDisclaimer} onAccept={handleAcceptDisclaimer}
currentLanguage={currentLanguage} currentLanguage={currentLanguage}
onLanguageChange={changeLanguage} onLanguageChange={changeLanguage}
currentTheme={uiSettings.theme || 'system'}
onThemeChange={(theme: 'light' | 'dark' | 'system') => updateUiSetting('theme', theme)}
t={t} t={t}
/> />
@@ -154,6 +190,8 @@ const MedPlanAssistant = () => {
t={t} t={t}
pkParams={pkParams} pkParams={pkParams}
days={days} days={days}
profiles={profiles}
activeProfileId={activeProfileId}
therapeuticRange={therapeuticRange} therapeuticRange={therapeuticRange}
doseIncrement={doseIncrement} doseIncrement={doseIncrement}
uiSettings={uiSettings} uiSettings={uiSettings}
@@ -161,15 +199,27 @@ const MedPlanAssistant = () => {
onUpdateTherapeuticRange={(key: any, value: any) => updateNestedState('therapeuticRange', key, value)} onUpdateTherapeuticRange={(key: any, value: any) => updateNestedState('therapeuticRange', key, value)}
onUpdateUiSetting={(key: any, value: any) => updateUiSetting(key as any, value)} onUpdateUiSetting={(key: any, value: any) => updateUiSetting(key as any, value)}
onImportDays={(importedDays: any) => updateState('days', importedDays)} onImportDays={(importedDays: any) => updateState('days', importedDays)}
onImportProfiles={(importedProfiles: any, newActiveProfileId: string) => {
updateState('profiles', importedProfiles);
updateState('activeProfileId', newActiveProfileId);
const newActiveProfile = importedProfiles.find((p: any) => p.id === newActiveProfileId);
if (newActiveProfile) {
updateState('days', newActiveProfile.days);
}
}}
onDeleteData={handleDeleteData}
/> />
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto" style={{
// TODO solution not ideal for mobile, consider https://tailwindcss.com/docs/responsive-design
minWidth: '480px'
}}>
<header className="mb-8"> <header className="mb-8">
<div className="flex justify-between items-start gap-4"> <div className="flex justify-between items-start gap-4">
<div> <div className="">
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">{t('appTitle')}</h1> <h1 className="text-3xl md:text-4xl font-bold tracking-tight">{t('appTitle')}</h1>
</div> </div>
<div className="flex flex-wrap gap-2 justify-end"> <div className="flex flex-wrap-reverse gap-2 justify-end">
<ThemeSelector <ThemeSelector
currentTheme={uiSettings.theme || 'system'} currentTheme={uiSettings.theme || 'system'}
onThemeChange={(theme: 'light' | 'dark' | 'system') => updateUiSetting('theme', theme)} onThemeChange={(theme: 'light' | 'dark' | 'system') => updateUiSetting('theme', theme)}
@@ -181,10 +231,10 @@ const MedPlanAssistant = () => {
<p className="text-muted-foreground mt-1">{t('appSubtitle')}</p> <p className="text-muted-foreground mt-1">{t('appSubtitle')}</p>
</header> </header>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Both Columns - Chart */} {/* Both Columns - Chart */}
<div className={`xl:col-span-2 bg-card p-6 rounded-lg border min-h-[600px] flex flex-col ${uiSettings.stickyChart ? 'sticky top-2 z-30 shadow-lg' : ''}`} <div className={`lg:col-span-2 bg-card p-6 rounded-lg border min-h-[600px] flex flex-col ${uiSettings.stickyChart ? 'sticky top-2 z-30 shadow-lg' : ''}`}
style={uiSettings.stickyChart ? { borderColor: 'hsl(var(--primary))' } : {}}> style={uiSettings.stickyChart ? { borderColor: 'hsl(var(--primary))' } : {}}>
<div className="flex flex-wrap items-center gap-3 justify-between mb-4"> <div className="flex flex-wrap items-center gap-3 justify-between mb-4">
<div className="flex flex-wrap justify-center gap-2"> <div className="flex flex-wrap justify-center gap-2">
@@ -244,6 +294,7 @@ const MedPlanAssistant = () => {
chartView={chartView} chartView={chartView}
showDayTimeOnXAxis={showDayTimeOnXAxis} showDayTimeOnXAxis={showDayTimeOnXAxis}
showDayReferenceLines={showDayReferenceLines} showDayReferenceLines={showDayReferenceLines}
showIntakeTimeLines={showIntakeTimeLines}
showTherapeuticRange={uiSettings.showTherapeuticRange ?? true} showTherapeuticRange={uiSettings.showTherapeuticRange ?? true}
therapeuticRange={therapeuticRange} therapeuticRange={therapeuticRange}
simulationDays={simulationDays} simulationDays={simulationDays}
@@ -256,7 +307,18 @@ const MedPlanAssistant = () => {
</div> </div>
{/* Left Column - Controls */} {/* Left Column - Controls */}
<div className="xl:col-span-1 space-y-6"> <div className="lg:col-span-1 space-y-6">
<ProfileSelector
profiles={profiles}
activeProfileId={activeProfileId}
hasUnsavedChanges={hasUnsavedChanges()}
onSwitchProfile={switchProfile}
onSaveProfile={saveProfile}
onSaveProfileAs={saveProfileAs}
onRenameProfile={updateProfileName}
onDeleteProfile={deleteProfile}
t={t}
/>
<DaySchedule <DaySchedule
days={days} days={days}
doseIncrement={doseIncrement} doseIncrement={doseIncrement}
@@ -272,7 +334,7 @@ const MedPlanAssistant = () => {
</div> </div>
{/* Right Column - Settings */} {/* Right Column - Settings */}
<div className="xl:col-span-1 space-y-6"> <div className="lg:col-span-1 space-y-4">
<Settings <Settings
pkParams={pkParams} pkParams={pkParams}
therapeuticRange={therapeuticRange} therapeuticRange={therapeuticRange}
@@ -282,7 +344,6 @@ const MedPlanAssistant = () => {
onUpdatePkParams={(key: any, value: any) => updateNestedState('pkParams', key, value)} onUpdatePkParams={(key: any, value: any) => updateNestedState('pkParams', key, value)}
onUpdateTherapeuticRange={(key: any, value: any) => updateNestedState('therapeuticRange', key, value)} onUpdateTherapeuticRange={(key: any, value: any) => updateNestedState('therapeuticRange', key, value)}
onUpdateUiSetting={updateUiSetting} onUpdateUiSetting={updateUiSetting}
onReset={handleReset}
onImportDays={(importedDays: any) => updateState('days', importedDays)} onImportDays={(importedDays: any) => updateState('days', importedDays)}
onOpenDataManagement={() => setShowDataManagement(true)} onOpenDataManagement={() => setShowDataManagement(true)}
t={t} t={t}
@@ -291,7 +352,8 @@ const MedPlanAssistant = () => {
</div> </div>
<footer className="mt-8 p-4 bg-muted rounded-lg text-sm text-muted-foreground border"> {/* Footer */}
<footer className="mt-8 p-4 bg-muted rounded-lg text-sm border">
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<h3 className="font-semibold mb-2 text-foreground">{t('importantNote')}</h3> <h3 className="font-semibold mb-2 text-foreground">{t('importantNote')}</h3>

View File

@@ -21,6 +21,7 @@ import { Switch } from './ui/switch';
import { Separator } from './ui/separator'; import { Separator } from './ui/separator';
import { Textarea } from './ui/textarea'; import { Textarea } from './ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { Trash2 } from 'lucide-react';
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -43,15 +44,20 @@ import {
parseImportFile, parseImportFile,
validateImportData, validateImportData,
importSettings, importSettings,
type ImportOptions,
} from '../utils/exportImport'; } from '../utils/exportImport';
import { APP_VERSION } from '../constants/defaults'; import { formatContent } from '../utils/contentFormatter';
import { APP_VERSION, MAX_PROFILES } from '../constants/defaults';
interface ExportImportOptions { interface ExportImportOptions {
includeSchedules: boolean; includeSchedules: boolean;
exportAllProfiles?: boolean;
restoreExamples?: boolean;
includeDiagramSettings: boolean; includeDiagramSettings: boolean;
includeSimulationSettings: boolean; includeSimulationSettings: boolean;
includePharmacoSettings: boolean; includePharmacoSettings: boolean;
includeAdvancedSettings: boolean; includeAdvancedSettings: boolean;
includeOtherData: boolean;
} }
interface DataManagementModalProps { interface DataManagementModalProps {
@@ -61,6 +67,8 @@ interface DataManagementModalProps {
// App state // App state
pkParams: any; pkParams: any;
days: any; days: any;
profiles?: any[];
activeProfileId?: string;
therapeuticRange: any; therapeuticRange: any;
doseIncrement: any; doseIncrement: any;
uiSettings: any; uiSettings: any;
@@ -69,6 +77,8 @@ interface DataManagementModalProps {
onUpdateTherapeuticRange: (key: string, value: any) => void; onUpdateTherapeuticRange: (key: string, value: any) => void;
onUpdateUiSetting: (key: string, value: any) => void; onUpdateUiSetting: (key: string, value: any) => void;
onImportDays?: (days: any) => void; onImportDays?: (days: any) => void;
onImportProfiles?: (profiles: any[], activeProfileId: string) => void;
onDeleteData?: (options: ExportImportOptions) => void;
} }
const DataManagementModal: React.FC<DataManagementModalProps> = ({ const DataManagementModal: React.FC<DataManagementModalProps> = ({
@@ -77,6 +87,8 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
t, t,
pkParams, pkParams,
days, days,
profiles,
activeProfileId,
therapeuticRange, therapeuticRange,
doseIncrement, doseIncrement,
uiSettings, uiSettings,
@@ -84,14 +96,18 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
onUpdateTherapeuticRange, onUpdateTherapeuticRange,
onUpdateUiSetting, onUpdateUiSetting,
onImportDays, onImportDays,
onImportProfiles,
onDeleteData,
}) => { }) => {
// Export/Import options // Export/Import options
const [exportOptions, setExportOptions] = useState<ExportImportOptions>({ const [exportOptions, setExportOptions] = useState<ExportImportOptions>({
includeSchedules: true, includeSchedules: true,
exportAllProfiles: true, // Default to exporting all profiles
includeDiagramSettings: true, includeDiagramSettings: true,
includeSimulationSettings: true, includeSimulationSettings: true,
includePharmacoSettings: true, includePharmacoSettings: true,
includeAdvancedSettings: true, includeAdvancedSettings: true,
includeOtherData: false,
}); });
const [importOptions, setImportOptions] = useState<ExportImportOptions>({ const [importOptions, setImportOptions] = useState<ExportImportOptions>({
@@ -100,6 +116,20 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
includeSimulationSettings: true, includeSimulationSettings: true,
includePharmacoSettings: true, includePharmacoSettings: true,
includeAdvancedSettings: true, includeAdvancedSettings: true,
includeOtherData: false,
});
const [mergeProfiles, setMergeProfiles] = useState(false);
// Deletion options - defaults: all except otherData
const [deletionOptions, setDeletionOptions] = useState<ExportImportOptions>({
includeSchedules: false,
restoreExamples: true, // Restore examples by default
includeDiagramSettings: false,
includeSimulationSettings: false,
includePharmacoSettings: false,
includeAdvancedSettings: false,
includeOtherData: false,
}); });
// File upload state // File upload state
@@ -118,6 +148,16 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
// Clipboard feedback // Clipboard feedback
const [copySuccess, setCopySuccess] = useState(false); const [copySuccess, setCopySuccess] = useState(false);
// Track which categories are available in the loaded JSON
const [availableCategories, setAvailableCategories] = useState<{
schedules: boolean;
diagramSettings: boolean;
simulationSettings: boolean;
pharmacoSettings: boolean;
advancedSettings: boolean;
otherData: boolean;
} | null>(null);
// Reset editor when modal opens/closes // Reset editor when modal opens/closes
React.useEffect(() => { React.useEffect(() => {
// TODO nice to have: use can decide behavior via checkbox (near editor) // TODO nice to have: use can decide behavior via checkbox (near editor)
@@ -142,6 +182,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
setJsonEditorContent(''); setJsonEditorContent('');
setJsonEditorExpanded(false); setJsonEditorExpanded(false);
setJsonValidationMessage({ type: null, message: '' }); setJsonValidationMessage({ type: null, message: '' });
setAvailableCategories(null);
setSelectedFile(null); setSelectedFile(null);
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
@@ -161,6 +202,8 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
const appState = { const appState = {
pkParams, pkParams,
days, days,
profiles: profiles || [],
activeProfileId: activeProfileId || '',
therapeuticRange, therapeuticRange,
doseIncrement, doseIncrement,
uiSettings, uiSettings,
@@ -181,6 +224,8 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
const appState = { const appState = {
pkParams, pkParams,
days, days,
profiles: profiles || [],
activeProfileId: activeProfileId || '',
therapeuticRange, therapeuticRange,
doseIncrement, doseIncrement,
uiSettings, uiSettings,
@@ -276,6 +321,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
const validateJsonContent = (content: string) => { const validateJsonContent = (content: string) => {
if (!content.trim()) { if (!content.trim()) {
setJsonValidationMessage({ type: null, message: '' }); setJsonValidationMessage({ type: null, message: '' });
setAvailableCategories(null);
return; return;
} }
@@ -288,6 +334,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
type: 'error', type: 'error',
message: t('importParseError'), message: t('importParseError'),
}); });
setAvailableCategories(null);
return; return;
} }
@@ -298,9 +345,21 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
type: 'error', type: 'error',
message: validation.errors.join(', '), message: validation.errors.join(', '),
}); });
setAvailableCategories(null);
return; return;
} }
// Detect which categories are present in the JSON
const categories = {
schedules: !!(importData.data.profiles || importData.data.schedules),
diagramSettings: !!importData.data.diagramSettings,
simulationSettings: !!importData.data.simulationSettings,
pharmacoSettings: !!importData.data.pharmacoSettings,
advancedSettings: !!importData.data.advancedSettings,
otherData: !!importData.data.otherData,
};
setAvailableCategories(categories);
if (validation.warnings.length > 0) { if (validation.warnings.length > 0) {
// Show success with warnings - warnings will be displayed separately // Show success with warnings - warnings will be displayed separately
setJsonValidationMessage({ setJsonValidationMessage({
@@ -320,6 +379,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
type: 'error', type: 'error',
message: t('pasteInvalidJson'), message: t('pasteInvalidJson'),
}); });
setAvailableCategories(null);
} }
}; };
@@ -384,15 +444,26 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
const currentState = { const currentState = {
pkParams, pkParams,
days, days,
profiles: profiles || [],
activeProfileId: activeProfileId || '',
therapeuticRange, therapeuticRange,
doseIncrement, doseIncrement,
uiSettings, uiSettings,
steadyStateConfig: { daysOnMedication: pkParams.advanced.steadyStateDays }, steadyStateConfig: { daysOnMedication: pkParams.advanced.steadyStateDays },
}; };
const newState = importSettings(currentState, importData.data, importOptions);
// Apply schedules const importOpts: ImportOptions = {
if (newState.days && importOptions.includeSchedules && onImportDays) { mergeProfiles: mergeProfiles
};
const newState = importSettings(currentState, importData.data, importOptions, importOpts);
// Apply profiles (new approach)
if (newState.profiles && newState.activeProfileId && importOptions.includeSchedules && onImportProfiles) {
onImportProfiles(newState.profiles, newState.activeProfileId);
}
// Fallback: Apply schedules (legacy)
else if (newState.days && importOptions.includeSchedules && onImportDays) {
onImportDays(newState.days); onImportDays(newState.days);
} }
@@ -431,8 +502,12 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
onClose(); onClose();
} catch (error) { } catch (error) {
console.error('Import error:', error); console.error('Import error:', error);
if (error instanceof Error && error.message.includes('exceed maximum')) {
alert(error.message);
} else {
alert(t('importError')); alert(t('importError'));
} }
}
}; };
// Clear JSON editor // Clear JSON editor
@@ -445,6 +520,48 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
} }
}; };
// Handle delete selected data
const handleDeleteData = () => {
// Check if any actual deletion categories are selected (excluding restoreExamples which is just an option)
const hasAnySelected =
deletionOptions.includeSchedules ||
deletionOptions.includeDiagramSettings ||
deletionOptions.includeSimulationSettings ||
deletionOptions.includePharmacoSettings ||
deletionOptions.includeAdvancedSettings ||
deletionOptions.includeOtherData;
if (!hasAnySelected) {
alert(t('deleteNoOptionsSelected'));
return;
}
// Build confirmation message listing what will be deleted
const categoriesToDelete = [];
if (deletionOptions.includeSchedules) categoriesToDelete.push(t('exportOptionSchedules'));
if (deletionOptions.includeDiagramSettings) categoriesToDelete.push(t('exportOptionDiagram'));
if (deletionOptions.includeSimulationSettings) categoriesToDelete.push(t('exportOptionSimulation'));
if (deletionOptions.includePharmacoSettings) categoriesToDelete.push(t('exportOptionPharmaco'));
if (deletionOptions.includeAdvancedSettings) categoriesToDelete.push(t('exportOptionAdvanced'));
if (deletionOptions.includeOtherData) categoriesToDelete.push(t('exportOptionOtherData'));
const confirmMessage =
t('deleteDataConfirmTitle') + '\n\n' +
categoriesToDelete.map(cat => `${cat}`).join('\n') + '\n\n' +
t('deleteDataConfirmWarning');
if (!window.confirm(confirmMessage)) {
return;
}
// Call deletion handler
if (onDeleteData) {
onDeleteData(deletionOptions);
alert(t('deleteDataSuccess'));
onClose();
}
};
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="max-w-4xl w-full max-h-[90vh] overflow-y-auto bg-background rounded-lg shadow-xl"> <div className="max-w-4xl w-full max-h-[90vh] overflow-y-auto bg-background rounded-lg shadow-xl">
@@ -494,6 +611,33 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
{t('exportOptionSchedules')} {t('exportOptionSchedules')}
</Label> </Label>
</div> </div>
{exportOptions.includeSchedules && profiles && profiles.length > 1 && (
<div className="flex items-center gap-3 pl-8">
<Switch
id="export-all-profiles"
checked={exportOptions.exportAllProfiles ?? true}
onCheckedChange={checked =>
setExportOptions({ ...exportOptions, exportAllProfiles: checked })
}
/>
<Label htmlFor="export-all-profiles" className="text-sm text-muted-foreground">
{t('exportAllProfiles')} ({profiles.length} {t('profiles')})
</Label>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<div className="text-xs max-w-xs">{formatContent(t('exportAllProfilesTooltip'))}</div>
</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Switch <Switch
id="export-diagram" id="export-diagram"
@@ -542,6 +686,31 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
{t('exportOptionAdvanced')} {t('exportOptionAdvanced')}
</Label> </Label>
</div> </div>
<div className="flex items-center gap-3">
<Switch
id="export-other"
checked={exportOptions.includeOtherData}
onCheckedChange={checked =>
setExportOptions({ ...exportOptions, includeOtherData: checked })
}
/>
<Label htmlFor="export-other" className="text-sm">
{t('exportOptionOtherData')}
</Label>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs max-w-xs">{t('exportOptionOtherDataTooltip')}</p>
</TooltipContent>
</Tooltip>
</div>
</div> </div>
</div> </div>
@@ -591,6 +760,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
<Switch <Switch
id="import-schedules" id="import-schedules"
checked={importOptions.includeSchedules} checked={importOptions.includeSchedules}
disabled={availableCategories !== null && !availableCategories.schedules}
onCheckedChange={checked => onCheckedChange={checked =>
setImportOptions({ ...importOptions, includeSchedules: checked }) setImportOptions({ ...importOptions, includeSchedules: checked })
} }
@@ -599,10 +769,36 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
{t('exportOptionSchedules')} {t('exportOptionSchedules')}
</Label> </Label>
</div> </div>
{importOptions.includeSchedules && profiles && (
<div className="flex items-center gap-3 pl-8">
<Switch
id="merge-profiles"
checked={mergeProfiles}
onCheckedChange={setMergeProfiles}
/>
<Label htmlFor="merge-profiles" className="text-sm text-muted-foreground">
{t('mergeProfiles')}
</Label>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<div className="text-xs max-w-xs">{formatContent(t('mergeProfilesTooltip'))}</div>
</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Switch <Switch
id="import-diagram" id="import-diagram"
checked={importOptions.includeDiagramSettings} checked={importOptions.includeDiagramSettings}
disabled={availableCategories !== null && !availableCategories.diagramSettings}
onCheckedChange={checked => onCheckedChange={checked =>
setImportOptions({ ...importOptions, includeDiagramSettings: checked }) setImportOptions({ ...importOptions, includeDiagramSettings: checked })
} }
@@ -615,6 +811,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
<Switch <Switch
id="import-simulation" id="import-simulation"
checked={importOptions.includeSimulationSettings} checked={importOptions.includeSimulationSettings}
disabled={availableCategories !== null && !availableCategories.simulationSettings}
onCheckedChange={checked => onCheckedChange={checked =>
setImportOptions({ ...importOptions, includeSimulationSettings: checked }) setImportOptions({ ...importOptions, includeSimulationSettings: checked })
} }
@@ -627,6 +824,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
<Switch <Switch
id="import-pharmaco" id="import-pharmaco"
checked={importOptions.includePharmacoSettings} checked={importOptions.includePharmacoSettings}
disabled={availableCategories !== null && !availableCategories.pharmacoSettings}
onCheckedChange={checked => onCheckedChange={checked =>
setImportOptions({ ...importOptions, includePharmacoSettings: checked }) setImportOptions({ ...importOptions, includePharmacoSettings: checked })
} }
@@ -639,6 +837,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
<Switch <Switch
id="import-advanced" id="import-advanced"
checked={importOptions.includeAdvancedSettings} checked={importOptions.includeAdvancedSettings}
disabled={availableCategories !== null && !availableCategories.advancedSettings}
onCheckedChange={checked => onCheckedChange={checked =>
setImportOptions({ ...importOptions, includeAdvancedSettings: checked }) setImportOptions({ ...importOptions, includeAdvancedSettings: checked })
} }
@@ -647,6 +846,32 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
{t('exportOptionAdvanced')} {t('exportOptionAdvanced')}
</Label> </Label>
</div> </div>
<div className="flex items-center gap-3">
<Switch
id="import-other"
checked={importOptions.includeOtherData}
disabled={availableCategories !== null && !availableCategories.otherData}
onCheckedChange={checked =>
setImportOptions({ ...importOptions, includeOtherData: checked })
}
/>
<Label htmlFor="import-other" className="text-sm">
{t('exportOptionOtherData')}
</Label>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs max-w-xs">{t('exportOptionOtherDataTooltip')}</p>
</TooltipContent>
</Tooltip>
</div>
</div> </div>
</div> </div>
@@ -743,7 +968,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
className={`flex items-center gap-2 text-sm ${ className={`flex items-center gap-2 text-sm ${
jsonValidationMessage.type === 'success' jsonValidationMessage.type === 'success'
? 'text-green-600 dark:text-green-400' ? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400' : 'error-text'
}`} }`}
> >
{jsonValidationMessage.type === 'success' ? ( {jsonValidationMessage.type === 'success' ? (
@@ -758,7 +983,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
{jsonValidationMessage.warnings.map((warning, index) => ( {jsonValidationMessage.warnings.map((warning, index) => (
<div <div
key={index} key={index}
className="bg-yellow-500 text-white text-xs p-2 rounded-md" className="warning-bubble text-xs p-2 rounded-md"
> >
{warning} {warning}
</div> </div>
@@ -791,22 +1016,152 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
)} )}
</div> </div>
<Separator /> {/* Apply Import Button - directly below JSON editor without separator */}
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-3">
<Button <Button
onClick={handleImport} onClick={handleImport}
className="flex-1" className="w-full"
size="lg" size="lg"
disabled={!jsonEditorContent && !selectedFile} disabled={!jsonEditorContent && !selectedFile}
> >
{t('importApplyButton')} {t('importApplyButton')}
</Button> </Button>
<Button onClick={onClose} variant="outline" className="flex-1" size="lg">
{t('closeDataManagement')} <Separator />
{/* Delete Specific Data Section */}
<div className="space-y-4">
<div className="flex i-tems-center gap-2">
<div className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
</div>
<h3 className="text-lg font-semibold">{t('deleteSpecificData')}</h3>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs max-w-xs">{t('deleteSpecificDataTooltip')}</p>
</TooltipContent>
</Tooltip>
</div>
{/* Warning Message */}
<div className="warning-bg-box rounded-md p-3 text-sm">
<p className="warning-text">{t('deleteDataWarning')}</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">{t('deleteSelectWhat')}</Label>
<div className="space-y-2 pl-4 [&_button[role=switch][data-state=checked]]:bg-destructive [&_button[role=switch][data-state=checked]]:border-destructive">
<div className="flex items-center gap-3">
<Switch
id="delete-schedules"
checked={deletionOptions.includeSchedules}
onCheckedChange={checked =>
setDeletionOptions({ ...deletionOptions, includeSchedules: checked })
}
/>
<Label htmlFor="delete-schedules" className="text-sm">
{t('exportOptionSchedules')}
</Label>
</div>
{deletionOptions.includeSchedules && (
<div className="flex items-center gap-3 pl-8">
<Switch
id="delete-restore-examples"
checked={deletionOptions.restoreExamples ?? false}
onCheckedChange={checked =>
setDeletionOptions({ ...deletionOptions, restoreExamples: checked })
}
/>
<Label htmlFor="delete-restore-examples" className="text-sm text-muted-foreground">
{t('deleteRestoreExamples')}
</Label>
</div>
)}
<div className="flex items-center gap-3">
<Switch
id="delete-diagram"
checked={deletionOptions.includeDiagramSettings}
onCheckedChange={checked =>
setDeletionOptions({ ...deletionOptions, includeDiagramSettings: checked })
}
/>
<Label htmlFor="delete-diagram" className="text-sm">
{t('exportOptionDiagram')}
</Label>
</div>
<div className="flex items-center gap-3">
<Switch
id="delete-simulation"
checked={deletionOptions.includeSimulationSettings}
onCheckedChange={checked =>
setDeletionOptions({ ...deletionOptions, includeSimulationSettings: checked })
}
/>
<Label htmlFor="delete-simulation" className="text-sm">
{t('exportOptionSimulation')}
</Label>
</div>
<div className="flex items-center gap-3">
<Switch
id="delete-pharmaco"
checked={deletionOptions.includePharmacoSettings}
onCheckedChange={checked =>
setDeletionOptions({ ...deletionOptions, includePharmacoSettings: checked })
}
/>
<Label htmlFor="delete-pharmaco" className="text-sm">
{t('exportOptionPharmaco')}
</Label>
</div>
<div className="flex items-center gap-3">
<Switch
id="delete-advanced"
checked={deletionOptions.includeAdvancedSettings}
onCheckedChange={checked =>
setDeletionOptions({ ...deletionOptions, includeAdvancedSettings: checked })
}
/>
<Label htmlFor="delete-advanced" className="text-sm">
{t('exportOptionAdvanced')}
</Label>
</div>
<div className="flex items-center gap-3">
<Switch
id="delete-other"
checked={deletionOptions.includeOtherData}
onCheckedChange={checked =>
setDeletionOptions({ ...deletionOptions, includeOtherData: checked })
}
/>
<Label htmlFor="delete-other" className="text-sm">
{t('exportOptionOtherData')}
</Label>
</div>
</div>
</div>
{/* Delete Button */}
<Button
onClick={handleDeleteData}
variant="destructive"
className="w-full"
size="lg"
>
{t('deleteDataButton')}
</Button> </Button>
</div> </div>
{/* Close Button */}
<Button onClick={onClose} variant="outline" className="w-full" size="lg">
{t('closeDataManagement')}
</Button>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -17,8 +17,10 @@ import { FormNumericInput } from './ui/form-numeric-input';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { IconButtonWithTooltip } from './ui/icon-button-with-tooltip'; import { IconButtonWithTooltip } from './ui/icon-button-with-tooltip';
import CollapsibleCardHeader from './ui/collapsible-card-header'; import CollapsibleCardHeader from './ui/collapsible-card-header';
import { Plus, Copy, Trash2, ArrowDownAZ, TrendingUp, TrendingDown, Utensils } from 'lucide-react'; import { Plus, Copy, Trash2, TrendingUp, TrendingDown, Utensils } from 'lucide-react';
import type { DayGroup } from '../constants/defaults'; import type { DayGroup } from '../constants/defaults';
import { MAX_DOSES_PER_DAY } from '../constants/defaults';
import { formatText } from '../utils/contentFormatter';
interface DayScheduleProps { interface DayScheduleProps {
days: DayGroup[]; days: DayGroup[];
@@ -50,6 +52,136 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
// Track collapsed state for each day (by day ID) // Track collapsed state for each day (by day ID)
const [collapsedDays, setCollapsedDays] = React.useState<Set<string>>(new Set()); const [collapsedDays, setCollapsedDays] = React.useState<Set<string>>(new Set());
// Track pending sort timeouts for debounced sorting
const [pendingSorts, setPendingSorts] = React.useState<Map<string, NodeJS.Timeout>>(new Map());
// Schedule a debounced sort for a day
const scheduleSort = React.useCallback((dayId: string) => {
// Cancel any existing pending sort for this day
const existingTimeout = pendingSorts.get(dayId);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
// Schedule new sort after delay
const timeoutId = setTimeout(() => {
onSortDoses(dayId);
setPendingSorts(prev => {
const newMap = new Map(prev);
newMap.delete(dayId);
return newMap;
});
}, 100);
setPendingSorts(prev => {
const newMap = new Map(prev);
newMap.set(dayId, timeoutId);
return newMap;
});
}, [pendingSorts, onSortDoses]);
// Handle time field blur - schedule a sort
const handleTimeBlur = React.useCallback((dayId: string) => {
scheduleSort(dayId);
}, [scheduleSort]);
// Wrap action handlers to cancel pending sorts and execute action, then sort
// Use this ONLY for actions that might affect dose order (like time changes)
const handleActionWithSort = React.useCallback((dayId: string, action: () => void) => {
// Cancel pending sort
const pendingTimeout = pendingSorts.get(dayId);
if (pendingTimeout) {
clearTimeout(pendingTimeout);
setPendingSorts(prev => {
const newMap = new Map(prev);
newMap.delete(dayId);
return newMap;
});
}
// Execute the action
action();
// Schedule sort after action completes
setTimeout(() => {
onSortDoses(dayId);
}, 50);
}, [pendingSorts, onSortDoses]);
// Handle actions that DON'T affect dose order (no sorting needed)
// This prevents unnecessary double state updates and improves performance
const handleActionWithoutSort = React.useCallback((action: () => void) => {
action();
}, []);
// Clean up pending timeouts on unmount
React.useEffect(() => {
return () => {
pendingSorts.forEach(timeout => clearTimeout(timeout));
};
}, [pendingSorts]);
// Calculate time delta from previous intake (across all days)
const calculateTimeDelta = (dayIndex: number, doseIndex: number): string => {
if (dayIndex === 0 && doseIndex === 0) {
return ""; // No delta for first dose of first day
}
const currentDay = days[dayIndex];
const currentDose = currentDay.doses[doseIndex];
if (!currentDose.time) return '';
const [currHours, currMinutes] = currentDose.time.split(':').map(Number);
const currentTotalMinutes = (dayIndex * 24 * 60) + (currHours * 60) + currMinutes;
let prevTotalMinutes = 0;
// Find previous dose
if (doseIndex > 0) {
// Previous dose is in the same day
const prevDose = currentDay.doses[doseIndex - 1];
if (prevDose.time) {
const [prevHours, prevMinutes] = prevDose.time.split(':').map(Number);
prevTotalMinutes = (dayIndex * 24 * 60) + (prevHours * 60) + prevMinutes;
}
} else if (dayIndex > 0) {
// Previous dose is the last dose of the previous day
const prevDay = days[dayIndex - 1];
if (prevDay.doses.length > 0) {
const lastDoseOfPrevDay = prevDay.doses[prevDay.doses.length - 1];
if (lastDoseOfPrevDay.time) {
const [prevHours, prevMinutes] = lastDoseOfPrevDay.time.split(':').map(Number);
prevTotalMinutes = ((dayIndex - 1) * 24 * 60) + (prevHours * 60) + prevMinutes;
}
}
}
const deltaMinutes = currentTotalMinutes - prevTotalMinutes;
// Resurn string "-" if delta is negative
// Thes shouldn't happen if sorting works correctly, but it can happen when time picker is open and
// inakes are temporarily not in correct order wihle picker is still open (sorting happens on blur)
if (deltaMinutes <= 0) {
return '-';
}
const deltaHours = Math.floor(deltaMinutes / 60);
const remainingMinutes = deltaMinutes % 60;
return `+${deltaHours}:${remainingMinutes.toString().padStart(2, '0')}`;
};
// Calculate dose index across all days
const getDoseGlobalIndex = (dayIndex: number, doseIndex: number): number => {
let globalIndex = 1;
for (let d = 0; d < dayIndex; d++) {
globalIndex += days[d].doses.length;
}
globalIndex += doseIndex + 1;
return globalIndex;
};
// Load and persist collapsed days state // Load and persist collapsed days state
React.useEffect(() => { React.useEffect(() => {
const savedCollapsed = localStorage.getItem('dayScheduleCollapsedDays_v1'); const savedCollapsed = localStorage.getItem('dayScheduleCollapsedDays_v1');
@@ -80,17 +212,6 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
}); });
}; };
// Check if doses are sorted chronologically
const isDaySorted = (day: DayGroup): boolean => {
for (let i = 1; i < day.doses.length; i++) {
const prevTime = day.doses[i - 1].time || '00:00';
const currTime = day.doses[i].time || '00:00';
if (prevTime > currTime) {
return false;
}
}
return true;
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -98,19 +219,27 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
// Get template day for comparison // Get template day for comparison
const templateDay = days.find(d => d.isTemplate); const templateDay = days.find(d => d.isTemplate);
// Calculate daily total
const dayTotal = day.doses.reduce((sum, dose) => sum + (parseFloat(dose.ldx) || 0), 0);
// Check for daily total warnings/errors
const isDailyTotalError = dayTotal > 200;
const isDailyTotalWarning = !isDailyTotalError && dayTotal > 70;
// Calculate differences for deviation days // Calculate differences for deviation days
let doseCountDiff = 0; let doseCountDiff = 0;
let totalMgDiff = 0; let totalMgDiff = 0;
if (!day.isTemplate && templateDay) { if (!day.isTemplate && templateDay) {
doseCountDiff = day.doses.length - templateDay.doses.length; 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); const templateTotal = templateDay.doses.reduce((sum, dose) => sum + (parseFloat(dose.ldx) || 0), 0);
totalMgDiff = dayTotal - templateTotal; totalMgDiff = dayTotal - templateTotal;
} }
// FIXME incomplete implementation of @container and @min-[497px]:
// TODO solution not ideal for mobile, consider https://tailwindcss.com/docs/responsive-design
return ( return (
<Card key={day.id}> <Card key={day.id} className="@container">
<CollapsibleCardHeader <CollapsibleCardHeader
title={day.isTemplate ? t('regularPlan') : t('alternativePlan')} title={day.isTemplate ? t('regularPlan') : t('alternativePlan')}
isCollapsed={collapsedDays.has(day.id)} isCollapsed={collapsedDays.has(day.id)}
@@ -134,13 +263,14 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
tooltip={t('removeDay')} tooltip={t('removeDay')}
size="sm" size="sm"
variant="outline" variant="outline"
className="border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground" className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
/> />
)} )}
</> </>
} }
> >
<Badge variant="secondary" className="text-xs"> <div className="flex flex-nowrap items-center gap-2">
<Badge variant="solid" className="text-xs font-bold">
{t('day')} {dayIndex + 1} {t('day')} {dayIndex + 1}
</Badge> </Badge>
{!day.isTemplate && doseCountDiff !== 0 ? ( {!day.isTemplate && doseCountDiff !== 0 ? (
@@ -152,7 +282,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
> >
<Badge <Badge
variant="outline" variant="outline"
className={`text-xs ${doseCountDiff > 0 ? 'bg-blue-50' : 'bg-orange-50'}`} className={`text-xs ${doseCountDiff > 0 ? 'badge-trend-up' : 'badge-trend-down'}`}
> >
{doseCountDiff > 0 ? <TrendingUp className="h-3 w-3 inline mr-1" /> : <TrendingDown className="h-3 w-3 inline mr-1" />} {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')} {day.doses.length} {day.doses.length === 1 ? t('dose') : t('doses')}
@@ -179,115 +309,173 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
> >
<Badge <Badge
variant="outline" variant="outline"
className={`text-xs ${totalMgDiff > 0 ? 'bg-blue-50' : 'bg-orange-50'}`} className={`text-xs ${
isDailyTotalError
? 'badge-error'
: isDailyTotalWarning
? 'badge-warning'
: totalMgDiff > 0
? 'badge-trend-up'
: 'badge-trend-down'
}`}
> >
{totalMgDiff > 0 ? <TrendingUp className="h-3 w-3 inline mr-1" /> : <TrendingDown className="h-3 w-3 inline mr-1" />} {!isDailyTotalError && !isDailyTotalWarning && (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 {dayTotal.toFixed(1)} mg
</Badge> </Badge>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p className="text-xs"> <p className="text-xs">
{totalMgDiff > 0 ? '+' : ''}{totalMgDiff.toFixed(1)} mg {t('comparedToRegularPlan')} {isDailyTotalError
? `${t('errorDailyTotalAbove200mg').replace('{{total}}', dayTotal.toFixed(1))}`
: isDailyTotalWarning
? `${t('warningDailyTotalAbove70mg').replace('{{total}}', dayTotal.toFixed(1))}`
: `${totalMgDiff > 0 ? '+' : ''}${totalMgDiff.toFixed(1)} mg ${t('comparedToRegularPlan')}`
}
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : ( ) : (
<Badge variant="outline" className="text-xs"> <Badge
{day.doses.reduce((sum, dose) => sum + (parseFloat(dose.ldx) || 0), 0).toFixed(1)} mg variant="outline"
className={`text-xs ${
isDailyTotalError
? 'badge-error'
: isDailyTotalWarning
? 'badge-warning'
: ''
}`}
>
{dayTotal.toFixed(1)} mg
</Badge> </Badge>
)} )}
</div>
</CollapsibleCardHeader> </CollapsibleCardHeader>
{/* Daily details (intakes) */}
{!collapsedDays.has(day.id) && ( {!collapsedDays.has(day.id) && (
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{/* Daily total warning/error box */}
{(isDailyTotalWarning || isDailyTotalError) && (
<div className={`p-3 rounded-md text-sm ${isDailyTotalError ? 'error-bg-box' : 'warning-bg-box'}`}>
{formatText(isDailyTotalError
? t('errorDailyTotalAbove200mg').replace('{{total}}', dayTotal.toFixed(1))
: t('warningDailyTotalAbove70mg').replace('{{total}}', dayTotal.toFixed(1))
)}
</div>
)}
{/* Dose table header */} {/* Dose table header */}
<div className="grid grid-cols-[100px_1fr_auto_auto] gap-2 text-sm font-medium text-muted-foreground"> <div className="grid items-center gap-0.5 text-sm font-medium text-muted-foreground" style={{gridTemplateColumns: '20px 172px 148px 30px 1fr'}}>
<div className="flex items-center gap-1"> <div className="flex justify-center">#</div>{/* Index header */}
<span>{t('time')}</span> <div>{t('time')}</div>{/* Time header */}
<Tooltip> <div>{t('ldx')} (mg)</div>{/* LDX header */}
<TooltipTrigger asChild> <div></div>{/* Buttons column (empty header) */}
<Button
type="button"
size="sm"
variant="ghost"
className={
isDaySorted(day)
? "h-6 w-6 p-0 text-muted-foreground hover:text-muted-foreground cursor-default"
: "h-6 w-6 p-0 text-primary hover:text-primary hover:bg-primary/10"
}
onClick={() => !isDaySorted(day) && onSortDoses(day.id)}
disabled={isDaySorted(day)}
>
<ArrowDownAZ className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{isDaySorted(day) ? t('sortByTimeSorted') : t('sortByTimeNeeded')}
</p>
</TooltipContent>
</Tooltip>
</div>
<div>{t('ldx')} (mg)</div>
<div className="text-center">
<Utensils className="h-4 w-4 inline" />
</div>
<div className="invisible">-</div>
</div> </div>
{/* Dose rows */} {/* Dose rows */}
{day.doses.map((dose) => { {day.doses.map((dose, doseIdx) => {
// Check for duplicate times // Check for duplicate times
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 // Check for zero dose
const isZeroDose = dose.ldx === '0' || dose.ldx === '0.0'; const isZeroDose = dose.ldx === '0' || dose.ldx === '0.0';
// Check for dose > 70 mg
const isHighDose = parseFloat(dose.ldx) > 70;
// Determine the error/warning message priority:
// 1. Daily total error (> 200mg) - ERROR
// 2. Daily total warning (> 70mg) - WARNING
// 3. Individual dose warning (zero dose or > 70mg) - WARNING
let doseErrorMessage;
let doseWarningMessage;
if (isDailyTotalError) {
doseErrorMessage = formatText(t('errorDailyTotalAbove200mg').replace('{{total}}', dayTotal.toFixed(1)));
} else if (isDailyTotalWarning) {
doseWarningMessage = formatText(t('warningDailyTotalAbove70mg').replace('{{total}}', dayTotal.toFixed(1)));
} else if (isZeroDose) {
doseWarningMessage = formatText(t('warningZeroDose'));
} else if (isHighDose) {
doseWarningMessage = formatText(t('warningDoseAbove70mg'));
}
const timeDelta = calculateTimeDelta(dayIndex, doseIdx);
const doseIndex = doseIdx + 1;
return ( return (
<div key={dose.id} className="grid grid-cols-[120px_1fr_auto_auto] gap-2 items-center"> <div key={dose.id} className="space-y-2">
<div className="grid items-center gap-0.5" style={{gridTemplateColumns: '20px 172px 148px 30px 1fr'}}>
{/* Intake index badge */}
<div className="flex justify-center">
<Badge variant="solid"
className="text-xs w-5 h-6 flex items-center justify-center px-1.5">
{doseIndex}
</Badge>
</div>
{/* Time input with delta badge attached (where applicable) */}
<div className="flex flex-nowrap items-center justify-center gap-0">
<FormTimeInput <FormTimeInput
value={dose.time} value={dose.time}
onChange={(value) => onUpdateDose(day.id, dose.id, 'time', value)} onChange={(value) => onUpdateDose(day.id, dose.id, 'time', value)}
onBlur={() => handleTimeBlur(day.id)}
required={true} required={true}
warning={hasDuplicateTime} warning={hasDuplicateTime}
errorMessage={t('errorTimeRequired')} errorMessage={formatText(t('errorTimeRequired'))}
warningMessage={t('warningDuplicateTime')} warningMessage={formatText(t('warningDuplicateTime'))}
/> />
<Badge variant={timeDelta ? "field" : "transparent"} className="rounded-l-none border-l-0 font-light italic text-muted-foreground text-xs w-12 h-6 flex justify-end px-1.5">
{timeDelta}
</Badge>
</div>
{/* LDX dose input */}
<FormNumericInput <FormNumericInput
value={dose.ldx} value={dose.ldx}
onChange={(value) => onUpdateDose(day.id, dose.id, 'ldx', value)} onChange={(value) => onUpdateDose(day.id, dose.id, 'ldx', value)}
increment={doseIncrement} increment={doseIncrement}
min={0} min={0}
unit="mg" max={200}
//unit="mg"
required={true} required={true}
warning={isZeroDose} error={isDailyTotalError}
errorMessage={t('errorNumberRequired')} warning={isDailyTotalWarning || isZeroDose || isHighDose}
warningMessage={t('warningZeroDose')} errorMessage={doseErrorMessage || formatText(t('errorNumberRequired'))}
warningMessage={doseWarningMessage}
inputWidth="w-[72px]"
/> />
{/* Fed/fasted toggle button */}
<IconButtonWithTooltip <IconButtonWithTooltip
onClick={() => onUpdateDoseField(day.id, dose.id, 'isFed', !dose.isFed)} onClick={() => handleActionWithoutSort(() => onUpdateDoseField(day.id, dose.id, 'isFed', !dose.isFed))}
icon={<Utensils className="h-4 w-4" />} icon={<Utensils className="h-4 w-4" />}
tooltip={dose.isFed ? t('doseWithFood') : t('doseFasted')} tooltip={dose.isFed ? t('doseWithFood') : t('doseFasted')}
size="sm" size="sm"
variant={dose.isFed ? "default" : "outline"} variant={dose.isFed ? "default" : "outline"}
className={`h-9 w-9 p-0 ${dose.isFed ? 'bg-orange-500 hover:bg-orange-600' : ''}`} className={`h-9 w-9 p-0 ${dose.isFed ? 'bg-orange-500 hover:bg-orange-600' : ''}`}
/> />
{/* Row action buttons - right aligned */}
<div className="flex flex-nowrap items-center justify-end gap-1">
<IconButtonWithTooltip <IconButtonWithTooltip
onClick={() => onRemoveDose(day.id, dose.id)} onClick={() => handleActionWithoutSort(() => onRemoveDose(day.id, dose.id))}
icon={<Trash2 className="h-4 w-4" />} icon={<Trash2 className="h-4 w-4" />}
tooltip={t('removeDose')} tooltip={t('removeDose')}
size="sm" size="sm"
variant="outline" variant="outline"
disabled={day.isTemplate && day.doses.length === 1} disabled={day.isTemplate && day.doses.length === 1}
className="h-9 w-9 p-0 border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:border-muted" className="h-9 w-9 p-0 text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:border-muted"
/> />
</div> </div>
</div>
</div>
); );
})} })}
{/* Add dose button */} {/* Add dose button */}
{day.doses.length < 5 && ( {day.doses.length < MAX_DOSES_PER_DAY && (
<Button <Button
onClick={() => onAddDose(day.id)} onClick={() => onAddDose(day.id)}
size="sm" size="sm"

View File

@@ -13,6 +13,7 @@ import React, { useState } from 'react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import LanguageSelector from './language-selector'; import LanguageSelector from './language-selector';
import ThemeSelector from './theme-selector';
import { PROJECT_REPOSITORY_URL } from '../constants/defaults'; import { PROJECT_REPOSITORY_URL } from '../constants/defaults';
interface DisclaimerModalProps { interface DisclaimerModalProps {
@@ -20,6 +21,8 @@ interface DisclaimerModalProps {
onAccept: () => void; onAccept: () => void;
currentLanguage: string; currentLanguage: string;
onLanguageChange: (lang: string) => void; onLanguageChange: (lang: string) => void;
currentTheme?: 'light' | 'dark' | 'system';
onThemeChange?: (theme: 'light' | 'dark' | 'system') => void;
t: (key: string) => string; t: (key: string) => string;
} }
@@ -28,6 +31,8 @@ const DisclaimerModal: React.FC<DisclaimerModalProps> = ({
onAccept, onAccept,
currentLanguage, currentLanguage,
onLanguageChange, onLanguageChange,
currentTheme = 'system',
onThemeChange,
t t
}) => { }) => {
const [sourcesExpanded, setSourcesExpanded] = useState(false); const [sourcesExpanded, setSourcesExpanded] = useState(false);
@@ -44,16 +49,25 @@ const DisclaimerModal: React.FC<DisclaimerModalProps> = ({
<CardTitle className="text-2xl font-bold"> <CardTitle className="text-2xl font-bold">
{t('disclaimerModalTitle')} {t('disclaimerModalTitle')}
</CardTitle> </CardTitle>
<p className="text-center text-muted-foreground mt-2"> <p className="text-left text-muted-foreground mt-2">
{t('disclaimerModalSubtitle')} {t('disclaimerModalSubtitle')}
</p> </p>
</div> </div>
<div className="flex flex-wrap-reverse gap-2 justify-end">
{onThemeChange && (
<ThemeSelector
currentTheme={currentTheme}
onThemeChange={onThemeChange}
t={t}
/>
)}
<LanguageSelector <LanguageSelector
currentLanguage={currentLanguage} currentLanguage={currentLanguage}
onLanguageChange={onLanguageChange} onLanguageChange={onLanguageChange}
t={t} t={t}
/> />
</div> </div>
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-6 pt-6"> <CardContent className="space-y-6 pt-6">
{/* Purpose */} {/* Purpose */}
@@ -95,7 +109,7 @@ const DisclaimerModal: React.FC<DisclaimerModalProps> = ({
<span className="text-2xl"></span> <span className="text-2xl"></span>
{t('disclaimerModalScheduleII')} {t('disclaimerModalScheduleII')}
</h3> </h3>
<p className="text-sm text-red-800 dark:text-red-300"> <p className="text-sm error-text">
{t('disclaimerModalScheduleIIText')} {t('disclaimerModalScheduleIIText')}
</p> </p>
</div> </div>

View File

@@ -0,0 +1,319 @@
/**
* Profile Selector Component
*
* Allows users to manage medication schedule profiles with create, save,
* save-as, and delete functionality. Provides a combobox-style interface
* for profile selection and management.
*
* @author Andreas Weyer
* @license MIT
*/
import React, { useState } from 'react';
import { Card, CardContent } from './ui/card';
import { Label } from './ui/label';
import { Input } from './ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { Save, Trash2, Plus, Pencil } from 'lucide-react';
import { IconButtonWithTooltip } from './ui/icon-button-with-tooltip';
import { MAX_PROFILES, type ScheduleProfile } from '../constants/defaults';
interface ProfileSelectorProps {
profiles: ScheduleProfile[];
activeProfileId: string;
hasUnsavedChanges: boolean;
onSwitchProfile: (profileId: string) => void;
onSaveProfile: () => void;
onSaveProfileAs: (name: string) => string | null;
onRenameProfile: (profileId: string, newName: string) => void;
onDeleteProfile: (profileId: string) => boolean;
t: (key: string) => string;
}
export const ProfileSelector: React.FC<ProfileSelectorProps> = ({
profiles,
activeProfileId,
hasUnsavedChanges,
onSwitchProfile,
onSaveProfile,
onSaveProfileAs,
onRenameProfile,
onDeleteProfile,
t,
}) => {
const [newProfileName, setNewProfileName] = useState('');
const [isSaveAsMode, setIsSaveAsMode] = useState(false);
const [isRenameMode, setIsRenameMode] = useState(false);
const [renameName, setRenameName] = useState('');
const activeProfile = profiles.find(p => p.id === activeProfileId);
const canDelete = profiles.length > 1;
const canCreateNew = profiles.length < MAX_PROFILES;
// Sort profiles alphabetically (case-insensitive)
const sortedProfiles = [...profiles].sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
);
const handleSelectChange = (value: string) => {
if (value === '__new__') {
// Enter "save as" mode
setIsSaveAsMode(true);
setIsRenameMode(false);
setNewProfileName('');
} else {
// Confirm before switching if there are unsaved changes
if (hasUnsavedChanges) {
if (!window.confirm(t('profileSwitchUnsavedConfirm'))) {
return;
}
}
onSwitchProfile(value);
setIsSaveAsMode(false);
setIsRenameMode(false);
}
};
const handleSaveAs = () => {
if (!newProfileName.trim()) {
return;
}
// Check for duplicate names
const isDuplicate = profiles.some(
p => p.name.toLowerCase() === newProfileName.trim().toLowerCase()
);
let finalName = newProfileName.trim();
if (isDuplicate) {
// Find next available suffix
let suffix = 2;
while (profiles.some(p => p.name.toLowerCase() === `${newProfileName.trim()} (${suffix})`.toLowerCase())) {
suffix++;
}
finalName = `${newProfileName.trim()} (${suffix})`;
}
const newProfileId = onSaveProfileAs(finalName);
if (newProfileId) {
setIsSaveAsMode(false);
setNewProfileName('');
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSaveAs();
} else if (e.key === 'Escape') {
setIsSaveAsMode(false);
setNewProfileName('');
}
};
const handleDelete = () => {
if (activeProfile && canDelete) {
if (window.confirm(t('profileDeleteConfirm')?.replace('{name}', activeProfile.name))) {
onDeleteProfile(activeProfile.id);
}
}
};
const handleStartRename = () => {
if (activeProfile) {
setIsRenameMode(true);
setIsSaveAsMode(false);
setRenameName(activeProfile.name);
}
};
const handleRename = () => {
if (!renameName.trim() || !activeProfile) {
return;
}
const trimmedName = renameName.trim();
// Check if name is unchanged
if (trimmedName === activeProfile.name) {
setIsRenameMode(false);
return;
}
// Check for duplicate names (excluding current profile)
const isDuplicate = profiles.some(
p => p.id !== activeProfile.id && p.name.toLowerCase() === trimmedName.toLowerCase()
);
if (isDuplicate) {
alert(t('profileNameAlreadyExists') || 'A schedule with this name already exists');
return;
}
onRenameProfile(activeProfile.id, trimmedName);
setIsRenameMode(false);
setRenameName('');
};
const handleRenameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleRename();
} else if (e.key === 'Escape') {
setIsRenameMode(false);
setRenameName('');
}
};
return (
<Card className="mb-4">
<CardContent className="pt-6">
<div className="space-y-2">
{/* Title label */}
<Label htmlFor="profile-selector" className="text-sm font-medium">
{t('savedPlans')}
</Label>
{/* Profile selector with integrated buttons */}
<div className="flex items-stretch">
{/* Profile selector / name input */}
{isSaveAsMode ? (
<Input
id="profile-selector"
type="text"
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('profileSaveAsPlaceholder')}
autoFocus
className="h-9 rounded-r-none border-r-0 w-[288px] bg-background"
/>
) : isRenameMode ? (
<Input
id="profile-selector"
type="text"
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onKeyDown={handleRenameKeyDown}
placeholder={t('profileRenamePlaceholder')}
autoFocus
className="h-9 rounded-r-none border-r-0 w-[288px] bg-background"
/>
) : (
<Select
value={activeProfileId}
onValueChange={handleSelectChange}
>
<SelectTrigger id="profile-selector" className="h-9 rounded-r-none border-r-0 w-[288px] bg-background">
<SelectValue>
{activeProfile?.name}
{hasUnsavedChanges && ' *'}
</SelectValue>
</SelectTrigger>
<SelectContent>
{sortedProfiles.map(profile => (
<SelectItem key={profile.id} value={profile.id}>
{profile.name}
</SelectItem>
))}
{canCreateNew && (
<>
<div className="my-1 h-px bg-border" />
<Tooltip>
<TooltipTrigger asChild>
<SelectItem value="__new__">
<div className="flex items-center gap-2">
<Plus className="h-4 w-4" />
<span>{t('profileSaveAsNewProfile')}</span>
</div>
</SelectItem>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">{t('profileSaveAs')}</p>
</TooltipContent>
</Tooltip>
</>
)}
</SelectContent>
</Select>
)}
{/* Save button - integrated */}
<IconButtonWithTooltip
onClick={isSaveAsMode ? handleSaveAs : isRenameMode ? handleRename : onSaveProfile}
icon={<Save className="h-4 w-4" />}
tooltip={isSaveAsMode ? t('profileSaveAs') : isRenameMode ? t('profileRename') : t('profileSave')}
disabled={(isSaveAsMode && !newProfileName.trim()) || (isRenameMode && !renameName.trim()) || (!isSaveAsMode && !isRenameMode && !hasUnsavedChanges)}
variant="outline"
size="icon"
className="rounded-none border-r-0"
/>
{/* Rename button - integrated */}
<IconButtonWithTooltip
onClick={handleStartRename}
icon={<Pencil className="h-4 w-4" />}
tooltip={t('profileRename')}
disabled={isSaveAsMode || isRenameMode}
variant="outline"
size="icon"
className="rounded-none border-r-0"
/>
{/* Delete button - integrated */}
<IconButtonWithTooltip
onClick={handleDelete}
icon={<Trash2 className="h-4 w-4" />}
tooltip={canDelete ? t('profileDelete') : t('profileDeleteDisabled')}
disabled={!canDelete || isSaveAsMode || isRenameMode}
variant="outline"
size="icon"
className="rounded-l-none text-destructive hover:bg-destructive hover:text-destructive-foreground"
/>
</div>
{/* Helper text for save-as mode */}
{isSaveAsMode && (
<div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground flex-1">
{t('profileSaveAsHelp')}
</p>
<button
onClick={() => {
setIsSaveAsMode(false);
setNewProfileName('');
}}
className="text-xs text-muted-foreground hover:text-foreground underline"
>
{t('cancel')}
</button>
</div>
)}
{/* Helper text for rename mode */}
{isRenameMode && (
<div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground flex-1">
{t('profileRenameHelp')}
</p>
<button
onClick={() => {
setIsRenameMode(false);
setRenameName('');
}}
className="text-xs text-muted-foreground hover:text-foreground underline"
>
{t('cancel')}
</button>
</div>
)}
</div>
</CardContent>
</Card>
);
};

View File

@@ -10,6 +10,7 @@
*/ */
import React from 'react'; import React from 'react';
import { useWindowSize } from '../hooks/useWindowSize';
import { Card, CardContent } from './ui/card'; import { Card, CardContent } from './ui/card';
import { Label } from './ui/label'; import { Label } from './ui/label';
import { Switch } from './ui/switch'; import { Switch } from './ui/switch';
@@ -18,9 +19,11 @@ import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { FormNumericInput } from './ui/form-numeric-input'; import { FormNumericInput } from './ui/form-numeric-input';
import { FormSelect } from './ui/form-select';
import CollapsibleCardHeader from './ui/collapsible-card-header'; import CollapsibleCardHeader from './ui/collapsible-card-header';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { getDefaultState } from '../constants/defaults'; import { getDefaultState } from '../constants/defaults';
import { formatContent, formatText } from '../utils/contentFormatter';
/** /**
* Helper function to create translation interpolation values for defaults. * Helper function to create translation interpolation values for defaults.
@@ -33,8 +36,11 @@ const getDefaultsForTranslation = (pkParams: any, therapeuticRange: any, uiSetti
// UI Settings // UI Settings
simulationDays: defaults.uiSettings.simulationDays, simulationDays: defaults.uiSettings.simulationDays,
displayedDays: defaults.uiSettings.displayedDays, displayedDays: defaults.uiSettings.displayedDays,
yAxisMin: defaults.uiSettings.yAxisMin,
yAxisMax: defaults.uiSettings.yAxisMax,
therapeuticRangeMin: defaults.therapeuticRange.min, therapeuticRangeMin: defaults.therapeuticRange.min,
therapeuticRangeMax: defaults.therapeuticRange.max, therapeuticRangeMax: defaults.therapeuticRange.max,
showDayTimeOnXAxis: defaults.uiSettings.showDayTimeOnXAxis,
// PK Parameters // PK Parameters
damphHalfLife: defaults.pkParams.damph.halfLife, damphHalfLife: defaults.pkParams.damph.halfLife,
@@ -44,12 +50,15 @@ const getDefaultsForTranslation = (pkParams: any, therapeuticRange: any, uiSetti
// Advanced Settings // Advanced Settings
standardVdValue: defaults.pkParams.advanced.standardVd?.preset === 'adult' ? '377' : defaults.pkParams.advanced.standardVd?.preset === 'child' ? '175' : defaults.pkParams.advanced.standardVd?.customValue || '377', standardVdValue: defaults.pkParams.advanced.standardVd?.preset === 'adult' ? '377' : defaults.pkParams.advanced.standardVd?.preset === 'child' ? '175' : defaults.pkParams.advanced.standardVd?.customValue || '377',
standardVdPreset: defaults.pkParams.advanced.standardVd?.preset || 'adult', standardVdPreset: defaults.pkParams.advanced.standardVd?.preset || 'adult',
bodyWeight: defaults.pkParams.advanced.weightBasedVd.bodyWeight, customVdValue: defaults.pkParams.advanced.standardVd.customValue,
bodyWeight: defaults.pkParams.advanced.standardVd.bodyWeight,
tmaxDelay: defaults.pkParams.advanced.foodEffect.tmaxDelay, tmaxDelay: defaults.pkParams.advanced.foodEffect.tmaxDelay,
phTendency: defaults.pkParams.advanced.urinePh.phTendency,
fOral: defaults.pkParams.advanced.fOral, fOral: defaults.pkParams.advanced.fOral,
fOralPercent: String((parseFloat(defaults.pkParams.advanced.fOral) * 100).toFixed(1)), fOralPercent: String((parseFloat(defaults.pkParams.advanced.fOral) * 100).toFixed(1)),
steadyStateDays: defaults.pkParams.advanced.steadyStateDays, steadyStateDays: defaults.pkParams.advanced.steadyStateDays,
urinePh: defaults.pkParams.advanced.urinePh.mode,
ageGroup: defaults.pkParams.advanced.ageGroup?.preset || 'adult',
renalFunctionSeverity: defaults.pkParams.advanced.renalFunction?.severity || 'normal',
}; };
}; };
@@ -69,45 +78,6 @@ const tWithDefaults = (translationFn: any, key: string, defaults: Record<string,
return result; return result;
}; };
/**
* Helper function to render tooltip content with inline source links.
* Parses [link text](url) markdown-style syntax and renders as clickable links.
* @example "See [this study](https://example.com)" → clickable link within tooltip
*/
const renderTooltipWithLinks = (text: string): React.ReactNode => {
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match;
while ((match = linkRegex.exec(text)) !== null) {
// Add text before link
if (match.index > lastIndex) {
parts.push(text.substring(lastIndex, match.index));
}
// Add link
parts.push(
<a
key={`link-${match.index}`}
href={match[2]}
target="_blank"
rel="noopener noreferrer"
className="underline italic text-yellow-300 hover:text-yellow-200 cursor-pointer"
>
{match[1]}
</a>
);
lastIndex = linkRegex.lastIndex;
}
// Add remaining text
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex));
}
return parts.length > 0 ? parts : text;
};
const Settings = ({ const Settings = ({
pkParams, pkParams,
therapeuticRange, therapeuticRange,
@@ -117,13 +87,13 @@ const Settings = ({
onUpdatePkParams, onUpdatePkParams,
onUpdateTherapeuticRange, onUpdateTherapeuticRange,
onUpdateUiSetting, onUpdateUiSetting,
onReset,
onImportDays, onImportDays,
onOpenDataManagement, onOpenDataManagement,
t t
}: any) => { }: any) => {
const { showDayTimeOnXAxis, yAxisMin, yAxisMax, showTemplateDay, simulationDays, displayedDays } = uiSettings; const { showDayTimeOnXAxis, yAxisMin, yAxisMax, showTemplateDay, simulationDays, displayedDays } = uiSettings;
const showDayReferenceLines = (uiSettings as any).showDayReferenceLines ?? true; const showDayReferenceLines = (uiSettings as any).showDayReferenceLines ?? true;
const showIntakeTimeLines = (uiSettings as any).showIntakeTimeLines ?? false;
const showTherapeuticRange = (uiSettings as any).showTherapeuticRange ?? true; const showTherapeuticRange = (uiSettings as any).showTherapeuticRange ?? true;
const steadyStateDaysEnabled = (uiSettings as any).steadyStateDaysEnabled ?? true; const steadyStateDaysEnabled = (uiSettings as any).steadyStateDaysEnabled ?? true;
@@ -135,20 +105,13 @@ const Settings = ({
// Track which tooltip is currently open (for mobile touch interaction) // Track which tooltip is currently open (for mobile touch interaction)
const [openTooltipId, setOpenTooltipId] = React.useState<string | null>(null); const [openTooltipId, setOpenTooltipId] = React.useState<string | null>(null);
// Track window width for responsive tooltip positioning // Validation state for range inputs
const [isNarrowScreen, setIsNarrowScreen] = React.useState( const [therapeuticRangeError, setTherapeuticRangeError] = React.useState<string>('');
typeof window !== 'undefined' ? window.innerWidth < 640 : false const [yAxisRangeError, setYAxisRangeError] = React.useState<string>('');
);
// Update narrow screen state on window resize // Track window width for responsive tooltip positioning using debounced hook
React.useEffect(() => { const { width: windowWidth } = useWindowSize(150);
const handleResize = () => { const isNarrowScreen = windowWidth < 640;
setIsNarrowScreen(window.innerWidth < 640);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Determine tooltip side based on screen width // Determine tooltip side based on screen width
const tooltipSide = isNarrowScreen ? 'top' : 'right'; const tooltipSide = isNarrowScreen ? 'top' : 'right';
@@ -227,6 +190,27 @@ const Settings = ({
// Create defaults object for translation interpolation // Create defaults object for translation interpolation
const defaultsForT = getDefaultsForTranslation(pkParams, therapeuticRange, uiSettings); const defaultsForT = getDefaultsForTranslation(pkParams, therapeuticRange, uiSettings);
// Validate range inputs
React.useEffect(() => {
// Therapeutic range validation (blocking error)
const minTherapeutic = parseFloat(therapeuticRange.min);
const maxTherapeutic = parseFloat(therapeuticRange.max);
if (!isNaN(minTherapeutic) && !isNaN(maxTherapeutic) && minTherapeutic >= maxTherapeutic) {
setTherapeuticRangeError(t('errorTherapeuticRangeInvalid'));
} else {
setTherapeuticRangeError('');
}
// Y-axis range validation (non-blocking warning)
const minYAxis = parseFloat(yAxisMin);
const maxYAxis = parseFloat(yAxisMax);
if (!isNaN(minYAxis) && !isNaN(maxYAxis) && minYAxis >= maxYAxis) {
setYAxisRangeError(t('errorYAxisRangeInvalid'));
} else {
setYAxisRangeError('');
}
}, [therapeuticRange.min, therapeuticRange.max, yAxisMin, yAxisMax, t]);
// Helper to update nested advanced settings // Helper to update nested advanced settings
const updateAdvanced = (category: string, key: string, value: any) => { const updateAdvanced = (category: string, key: string, value: any) => {
onUpdatePkParams('advanced', { onUpdatePkParams('advanced', {
@@ -288,7 +272,7 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{tWithDefaults(t, 'showTemplateDayTooltip', defaultsForT)}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'showTemplateDayTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@@ -317,7 +301,36 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{tWithDefaults(t, 'showDayReferenceLinesTooltip', defaultsForT)}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'showDayReferenceLinesTooltip', defaultsForT))}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<Switch
id="showIntakeTimeLines"
checked={showIntakeTimeLines}
onCheckedChange={checked => onUpdateUiSetting('showIntakeTimeLines', checked)}
/>
<Label htmlFor="showIntakeTimeLines" className="font-medium">
{t('showIntakeTimeLines')}
</Label>
<Tooltip open={openTooltipId === 'showIntakeTimeLines'} onOpenChange={(open) => setOpenTooltipId(open ? 'showIntakeTimeLines' : null)}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleTooltipToggle('showIntakeTimeLines')}
onTouchStart={handleTooltipToggle('showIntakeTimeLines')}
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label={t('showIntakeTimeLinesTooltip')}
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'showIntakeTimeLinesTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@@ -346,12 +359,12 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{tWithDefaults(t, 'showTherapeuticRangeLinesTooltip', defaultsForT)}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'showTherapeuticRangeLinesTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
{showTherapeuticRange && ( {showTherapeuticRange && (
<div className="ml-8 space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="font-medium"> <Label className="font-medium">
{t('therapeuticRange')} {t('therapeuticRange')}
@@ -369,7 +382,7 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{tWithDefaults(t, 'therapeuticRangeTooltip', defaultsForT)}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'therapeuticRangeTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@@ -379,9 +392,14 @@ const Settings = ({
onChange={val => onUpdateTherapeuticRange('min', val)} onChange={val => onUpdateTherapeuticRange('min', val)}
increment={0.5} increment={0.5}
min={0} min={0}
max={500}
placeholder={t('min')} placeholder={t('min')}
required={true} required={false}
errorMessage={t('errorTherapeuticRangeMinRequired') || 'Minimum therapeutic range is required'} error={!!therapeuticRangeError}
errorMessage={formatText(therapeuticRangeError)}
showResetButton={true}
defaultValue={defaultsForT.therapeuticRangeMin}
allowEmpty={true}
/> />
<span className="text-muted-foreground">-</span> <span className="text-muted-foreground">-</span>
<FormNumericInput <FormNumericInput
@@ -389,10 +407,15 @@ const Settings = ({
onChange={val => onUpdateTherapeuticRange('max', val)} onChange={val => onUpdateTherapeuticRange('max', val)}
increment={0.5} increment={0.5}
min={0} min={0}
max={500}
placeholder={t('max')} placeholder={t('max')}
unit="ng/ml" unit="ng/ml"
required={true} required={false}
errorMessage={t('errorTherapeuticRangeMaxRequired') || 'Maximum therapeutic range is required'} error={!!therapeuticRangeError}
errorMessage={formatText(therapeuticRangeError)}
showResetButton={true}
defaultValue={defaultsForT.therapeuticRangeMax}
allowEmpty={true}
/> />
</div> </div>
</div> </div>
@@ -417,7 +440,7 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{tWithDefaults(t, 'displayedDaysTooltip', defaultsForT)}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'displayedDaysTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@@ -429,7 +452,9 @@ const Settings = ({
max={parseInt(simulationDays, 10) || 3} max={parseInt(simulationDays, 10) || 3}
unit={t('unitDays')} unit={t('unitDays')}
required={true} required={true}
errorMessage={t('errorNumberRequired')} errorMessage={formatText(t('errorNumberRequired'))}
showResetButton={true}
defaultValue={defaultsForT.displayedDays}
/> />
</div> </div>
@@ -449,7 +474,7 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{tWithDefaults(t, 'yAxisRangeTooltip', defaultsForT)}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'yAxisRangeTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@@ -459,9 +484,13 @@ const Settings = ({
onChange={val => onUpdateUiSetting('yAxisMin', val)} onChange={val => onUpdateUiSetting('yAxisMin', val)}
increment={1} increment={1}
min={0} min={0}
max={500}
placeholder={t('auto')} placeholder={t('auto')}
allowEmpty={true} allowEmpty={true}
clearButton={true} showResetButton={true}
defaultValue={defaultsForT.yAxisMin}
warning={!!yAxisRangeError}
warningMessage={formatText(yAxisRangeError)}
/> />
<span className="text-muted-foreground">-</span> <span className="text-muted-foreground">-</span>
<FormNumericInput <FormNumericInput
@@ -469,10 +498,14 @@ const Settings = ({
onChange={val => onUpdateUiSetting('yAxisMax', val)} onChange={val => onUpdateUiSetting('yAxisMax', val)}
increment={1} increment={1}
min={0} min={0}
max={500}
placeholder={t('auto')} placeholder={t('auto')}
unit="ng/ml" unit="ng/ml"
allowEmpty={true} allowEmpty={true}
clearButton={true} showResetButton={true}
defaultValue={defaultsForT.yAxisMax}
warning={!!yAxisRangeError}
warningMessage={formatText(yAxisRangeError)}
/> />
</div> </div>
</div> </div>
@@ -481,13 +514,13 @@ const Settings = ({
<div className="space-y-2"> <div className="space-y-2">
<Label className="font-medium">{t('xAxisTimeFormat')}</Label> <Label className="font-medium">{t('xAxisTimeFormat')}</Label>
<Select <FormSelect
value={showDayTimeOnXAxis} value={showDayTimeOnXAxis}
onValueChange={value => onUpdateUiSetting('showDayTimeOnXAxis', value)} onValueChange={value => onUpdateUiSetting('showDayTimeOnXAxis', value)}
showResetButton={true}
defaultValue={defaultsForT.showDayTimeOnXAxis}
triggerClassName="w-[288px]"
> >
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -520,7 +553,7 @@ const Settings = ({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</SelectContent> </SelectContent>
</Select> </FormSelect>
</div> </div>
</CardContent> </CardContent>
)} )}
@@ -551,7 +584,7 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{tWithDefaults(t, 'simulationDurationTooltip', defaultsForT)}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'simulationDurationTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@@ -563,7 +596,9 @@ const Settings = ({
max={7} max={7}
unit={t('unitDays')} unit={t('unitDays')}
required={true} required={true}
errorMessage={t('errorNumberRequired')} errorMessage={formatText(t('errorNumberRequired'))}
showResetButton={true}
defaultValue={defaultsForT.simulationDays}
/> />
</div> </div>
@@ -601,12 +636,12 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{tWithDefaults(t, 'steadyStateDaysTooltip', defaultsForT)}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'steadyStateDaysTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
{steadyStateDaysEnabled && ( {steadyStateDaysEnabled && (
<div className="ml-8 space-y-2"> <div className="space-y-2">
<FormNumericInput <FormNumericInput
value={pkParams.advanced.steadyStateDays} value={pkParams.advanced.steadyStateDays}
onChange={val => updateAdvancedDirect('steadyStateDays', val)} onChange={val => updateAdvancedDirect('steadyStateDays', val)}
@@ -615,6 +650,8 @@ const Settings = ({
max={7} max={7}
unit={t('unitDays')} unit={t('unitDays')}
required={true} required={true}
showResetButton={true}
defaultValue={defaultsForT.steadyStateDays}
/> />
</div> </div>
)} )}
@@ -649,7 +686,7 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'halfLifeTooltip', defaultsForT))}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'halfLifeTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@@ -658,13 +695,15 @@ const Settings = ({
onChange={val => onUpdatePkParams('damph', { ...pkParams.damph, halfLife: val })} onChange={val => onUpdatePkParams('damph', { ...pkParams.damph, halfLife: val })}
increment={0.5} increment={0.5}
min={5} min={5}
max={34} max={50}
unit="h" unit="h"
required={true} required={true}
warning={eliminationWarning && !eliminationExtreme} warning={eliminationWarning && !eliminationExtreme}
error={eliminationExtreme} error={eliminationExtreme}
warningMessage={t('warningEliminationOutOfRange')} warningMessage={formatText(t('warningEliminationOutOfRange'))}
errorMessage={t('errorEliminationHalfLifeRequired')} errorMessage={formatText(t('errorEliminationHalfLifeRequired'))}
showResetButton={true}
defaultValue={defaultsForT.damphHalfLife}
/> />
</div> </div>
@@ -687,7 +726,7 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{tWithDefaults(t, 'conversionHalfLifeTooltip', defaultsForT)}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'conversionHalfLifeTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@@ -696,12 +735,14 @@ const Settings = ({
onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, halfLife: val })} onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, halfLife: val })}
increment={0.1} increment={0.1}
min={0.5} min={0.5}
max={2} max={5}
unit="h" unit="h"
required={true} required={true}
warning={conversionWarning} warning={conversionWarning}
warningMessage={t('warningConversionOutOfRange')} warningMessage={formatText(t('warningConversionOutOfRange'))}
errorMessage={t('errorConversionHalfLifeRequired')} errorMessage={formatText(t('errorConversionHalfLifeRequired'))}
showResetButton={true}
defaultValue={defaultsForT.ldxHalfLife}
/> />
</div> </div>
@@ -721,7 +762,7 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{tWithDefaults(t, 'absorptionHalfLifeTooltip', defaultsForT)}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'absorptionHalfLifeTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@@ -730,12 +771,14 @@ const Settings = ({
onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, absorptionHalfLife: val })} onChange={val => onUpdatePkParams('ldx', { ...pkParams.ldx, absorptionHalfLife: val })}
increment={0.1} increment={0.1}
min={0.5} min={0.5}
max={2} max={5}
unit="h" unit="h"
required={true} required={true}
warning={absorptionWarning} warning={absorptionWarning}
warningMessage={t('warningAbsorptionOutOfRange')} warningMessage={formatText(t('warningAbsorptionOutOfRange'))}
errorMessage={t('errorAbsorptionRateRequired')} errorMessage={formatText(t('errorAbsorptionRateRequired'))}
showResetButton={true}
defaultValue={defaultsForT.ldxAbsorptionHalfLife}
/> />
</div> </div>
</CardContent> </CardContent>
@@ -751,8 +794,8 @@ const Settings = ({
/> />
{isAdvancedExpanded && ( {isAdvancedExpanded && (
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 text-sm"> <div className="warning-bg-box rounded-md p-3 text-sm">
<p className="text-yellow-800 dark:text-yellow-200">{t('advancedSettingsWarning')}</p> <p className="warning-text">{t('advancedSettingsWarning')}</p>
</div> </div>
{/* Standard Volume of Distribution */} {/* Standard Volume of Distribution */}
@@ -772,7 +815,7 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'standardVdTooltip', { <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'standardVdTooltip', {
...defaultsForT, ...defaultsForT,
standardVdValue: pkParams.advanced.standardVd?.preset === 'adult' ? '377' : pkParams.advanced.standardVd?.preset === 'child' ? '175' : pkParams.advanced.standardVd?.customValue || '377', standardVdValue: pkParams.advanced.standardVd?.preset === 'adult' ? '377' : pkParams.advanced.standardVd?.preset === 'child' ? '175' : pkParams.advanced.standardVd?.customValue || '377',
standardVdPreset: t(`standardVdPreset${pkParams.advanced.standardVd?.preset?.charAt(0).toUpperCase()}${pkParams.advanced.standardVd?.preset?.slice(1)}` || 'standardVdPresetAdult') standardVdPreset: t(`standardVdPreset${pkParams.advanced.standardVd?.preset?.charAt(0).toUpperCase()}${pkParams.advanced.standardVd?.preset?.slice(1)}` || 'standardVdPresetAdult')
@@ -780,77 +823,43 @@ const Settings = ({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Select <FormSelect
value={pkParams.advanced.standardVd?.preset || 'adult'} value={pkParams.advanced.standardVd?.preset || 'adult'}
onValueChange={(value: 'adult' | 'child' | 'custom') => updateAdvanced('standardVd', 'preset', value)} onValueChange={(value) => updateAdvanced('standardVd', 'preset', value as 'adult' | 'child' | 'custom' | 'weight-based')}
showResetButton={true}
defaultValue={defaultsForT.standardVdPreset}
triggerClassName="w-[288px]"
> >
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="adult">{t('standardVdPresetAdult')}</SelectItem> <SelectItem value="adult">{t('standardVdPresetAdult')}</SelectItem>
<SelectItem value="child">{t('standardVdPresetChild')}</SelectItem> <SelectItem value="child">{t('standardVdPresetChild')}</SelectItem>
<SelectItem value="custom">{t('standardVdPresetCustom')}</SelectItem> <SelectItem value="custom">{t('standardVdPresetCustom')}</SelectItem>
<SelectItem value="weight-based">{t('standardVdPresetWeightBased')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </FormSelect>
{pkParams.advanced.weightBasedVd.enabled && ( {pkParams.advanced.standardVd?.preset === 'weight-based' && (
<div className="ml-0 mt-2 p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-xs text-blue-800 dark:text-blue-200"> <div className="ml-0 mt-2 p-2 info-bg-box rounded text-xs info-text">
Weight-based Vd is enabled below. This setting is currently overridden. {t('weightBasedVdInfo')}
</div> </div>
)} )}
{pkParams.advanced.standardVd?.preset === 'custom' && ( {pkParams.advanced.standardVd?.preset === 'custom' && (
<div className="ml-8 mt-2"> <div className="mt-2">
<Label className="text-sm font-medium">{t('customVdValue')}</Label> <Label className="text-sm font-medium">{t('customVdValue')}</Label>
<FormNumericInput <FormNumericInput
value={pkParams.advanced.standardVd?.customValue || '377'} value={pkParams.advanced.standardVd?.customValue || '377'}
onChange={val => updateAdvanced('standardVd', 'customValue', val)} onChange={val => updateAdvanced('standardVd', 'customValue', val)}
increment={10} increment={10}
min={50} min={50}
max={800} max={2000}
unit="L" unit="L"
required={true} required={true}
showResetButton={true}
defaultValue={defaultsForT.customVdValue}
/> />
</div> </div>
)} )}
</div> {pkParams.advanced.standardVd?.preset === 'weight-based' && (
<div className="mt-2">
<Separator className="my-4" />
{/* Weight-Based Vd */}
<div className="space-y-3">
{pkParams.advanced.weightBasedVd.enabled && (
<div className="p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-xs text-blue-800 dark:text-blue-200 mb-3">
When enabled, this overrides the Standard Vd setting above. Disable to use Standard Vd presets (Adult/Child/Custom).
</div>
)}
<div className="flex items-center gap-3">
<Switch
id="weightBasedVdEnabled"
checked={pkParams.advanced.weightBasedVd.enabled}
onCheckedChange={checked => updateAdvanced('weightBasedVd', 'enabled', checked)}
/>
<Label htmlFor="weightBasedVdEnabled" className="font-medium">
{t('weightBasedVdScaling')}
</Label>
<Tooltip open={openTooltipId === 'weightBasedVd'} onOpenChange={(open) => setOpenTooltipId(open ? 'weightBasedVd' : null)}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleTooltipToggle('weightBasedVd')}
onTouchStart={handleTooltipToggle('weightBasedVd')}
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label={t('weightBasedVdTooltip')}
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{tWithDefaults(t, 'weightBasedVdTooltip', defaultsForT)}</p>
</TooltipContent>
</Tooltip>
</div>
{pkParams.advanced.weightBasedVd.enabled && (
<div className="ml-8 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="text-sm font-medium">{t('bodyWeight')}</Label> <Label className="text-sm font-medium">{t('bodyWeight')}</Label>
<Tooltip open={openTooltipId === 'bodyWeight'} onOpenChange={(open) => setOpenTooltipId(open ? 'bodyWeight' : null)}> <Tooltip open={openTooltipId === 'bodyWeight'} onOpenChange={(open) => setOpenTooltipId(open ? 'bodyWeight' : null)}>
@@ -866,18 +875,20 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'bodyWeightTooltip', defaultsForT))}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'bodyWeightTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<FormNumericInput <FormNumericInput
value={pkParams.advanced.weightBasedVd.bodyWeight} value={pkParams.advanced.standardVd?.bodyWeight || '70'}
onChange={val => updateAdvanced('weightBasedVd', 'bodyWeight', val)} onChange={val => updateAdvanced('standardVd', 'bodyWeight', val)}
increment={1} increment={1}
min={20} min={20}
max={150} max={300}
unit={t('bodyWeightUnit')} unit={t('bodyWeightUnit')}
required={true} required={true}
showResetButton={true}
defaultValue={defaultsForT.bodyWeight}
/> />
</div> </div>
)} )}
@@ -885,8 +896,6 @@ const Settings = ({
<Separator className="my-4" /> <Separator className="my-4" />
<Separator className="my-4" />
{/* Food Effect Absorption Delay */} {/* Food Effect Absorption Delay */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -904,7 +913,7 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'tmaxDelayTooltip', defaultsForT))}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'tmaxDelayTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@@ -913,9 +922,11 @@ const Settings = ({
onChange={val => updateAdvanced('foodEffect', 'tmaxDelay', val)} onChange={val => updateAdvanced('foodEffect', 'tmaxDelay', val)}
increment={0.1} increment={0.1}
min={0} min={0}
max={2} max={5}
unit={t('tmaxDelayUnit')} unit={t('tmaxDelayUnit')}
required={true} required={true}
showResetButton={true}
defaultValue={defaultsForT.tmaxDelay}
/> />
</div> </div>
@@ -924,12 +935,7 @@ const Settings = ({
{/* Urine pH */} {/* Urine pH */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Switch <Label htmlFor="urinePHMode" className="font-medium">
id="urinePHEnabled"
checked={pkParams.advanced.urinePh.enabled}
onCheckedChange={checked => updateAdvanced('urinePh', 'enabled', checked)}
/>
<Label htmlFor="urinePHEnabled" className="font-medium">
{t('urinePHTendency')} {t('urinePHTendency')}
</Label> </Label>
<Tooltip open={openTooltipId === 'urinePH'} onOpenChange={(open) => setOpenTooltipId(open ? 'urinePH' : null)}> <Tooltip open={openTooltipId === 'urinePH'} onOpenChange={(open) => setOpenTooltipId(open ? 'urinePH' : null)}>
@@ -945,42 +951,27 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{tWithDefaults(t, 'urinePHTooltip', defaultsForT)}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'urinePHTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
{pkParams.advanced.urinePh.enabled && ( <div>
<div className="ml-8 space-y-2"> <FormSelect
<div className="flex items-center gap-2"> value={pkParams.advanced.urinePh.mode}
<Label className="text-sm font-medium">{t('urinePHValue')}</Label> onValueChange={(value) =>
<Tooltip open={openTooltipId === 'urinePHValue'} onOpenChange={(open) => setOpenTooltipId(open ? 'urinePHValue' : null)}> updateAdvanced('urinePh', 'mode', value as 'normal' | 'acidic' | 'alkaline')
<TooltipTrigger asChild> }
<button showResetButton={true}
type="button" defaultValue={defaultsForT.urinePh}
onClick={handleTooltipToggle('urinePHValue')} triggerClassName="w-[288px]"
onTouchStart={handleTooltipToggle('urinePHValue')}
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label={t('urinePHValueTooltip')}
> >
<Info className="h-4 w-4" /> <SelectContent>
</button> <SelectItem value="normal">{t('urinePHModeNormal')}</SelectItem>
</TooltipTrigger> <SelectItem value="acidic">{t('urinePHModeAcidic')}</SelectItem>
<TooltipContent side={tooltipSide}> <SelectItem value="alkaline">{t('urinePHModeAlkaline')}</SelectItem>
<p className="text-xs max-w-xs">{tWithDefaults(t, 'urinePHValueTooltip', defaultsForT)}</p> </SelectContent>
</TooltipContent> </FormSelect>
</Tooltip>
</div> </div>
<FormNumericInput
value={pkParams.advanced.urinePh.phTendency}
onChange={val => updateAdvanced('urinePh', 'phTendency', val)}
increment={0.1}
min={5.5}
max={8.0}
unit={t('phUnit')}
required={true}
/>
</div>
)}
</div> </div>
<Separator className="my-4" /> <Separator className="my-4" />
@@ -1002,25 +993,25 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'ageGroupTooltip', defaultsForT))}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'ageGroupTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Select <FormSelect
value={pkParams.advanced.ageGroup?.preset || 'adult'} value={pkParams.advanced.ageGroup?.preset || 'adult'}
onValueChange={(value: 'child' | 'adult' | 'custom') => { onValueChange={(value) => {
updateAdvancedDirect('ageGroup', { preset: value }); updateAdvancedDirect('ageGroup', { preset: value as 'child' | 'adult' | 'custom' });
}} }}
showResetButton={true}
defaultValue={defaultsForT.ageGroup}
triggerClassName="w-[288px]"
> >
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="adult">{t('ageGroupAdult')}</SelectItem> <SelectItem value="adult">{t('ageGroupAdult')}</SelectItem>
<SelectItem value="child">{t('ageGroupChild')}</SelectItem> <SelectItem value="child">{t('ageGroupChild')}</SelectItem>
<SelectItem value="custom">{t('ageGroupCustom')}</SelectItem> <SelectItem value="custom">{t('ageGroupCustom')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </FormSelect>
</div> </div>
<Separator className="my-4" /> <Separator className="my-4" />
@@ -1054,33 +1045,33 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'renalFunctionTooltip', defaultsForT))}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'renalFunctionTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
{(pkParams.advanced.renalFunction?.enabled) && ( {(pkParams.advanced.renalFunction?.enabled) && (
<div className="ml-8 space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="text-sm font-medium">{t('renalFunctionSeverity')}</Label> <Label className="text-sm font-medium">{t('renalFunctionSeverity')}</Label>
</div> </div>
<Select <FormSelect
value={pkParams.advanced.renalFunction?.severity || 'normal'} value={pkParams.advanced.renalFunction?.severity || 'normal'}
onValueChange={(value: 'normal' | 'mild' | 'severe') => { onValueChange={(value) => {
updateAdvancedDirect('renalFunction', { updateAdvancedDirect('renalFunction', {
enabled: true, enabled: true,
severity: value severity: value as 'normal' | 'mild' | 'severe'
}); });
}} }}
showResetButton={true}
defaultValue={defaultsForT.renalFunctionSeverity}
triggerClassName="w-full"
> >
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="normal">{t('renalFunctionNormal')}</SelectItem> <SelectItem value="normal">{t('renalFunctionNormal')}</SelectItem>
<SelectItem value="mild">{t('renalFunctionMild')}</SelectItem> <SelectItem value="mild">{t('renalFunctionMild')}</SelectItem>
<SelectItem value="severe">{t('renalFunctionSevere')}</SelectItem> <SelectItem value="severe">{t('renalFunctionSevere')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </FormSelect>
</div> </div>
)} )}
</div> </div>
@@ -1104,7 +1095,7 @@ const Settings = ({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={tooltipSide}> <TooltipContent side={tooltipSide}>
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'oralBioavailabilityTooltip', defaultsForT))}</p> <p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'oralBioavailabilityTooltip', defaultsForT))}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@@ -1115,6 +1106,8 @@ const Settings = ({
min={0.5} min={0.5}
max={1.0} max={1.0}
required={true} required={true}
showResetButton={true}
defaultValue={defaultsForT.fOral}
/> />
</div> </div>
</CardContent> </CardContent>
@@ -1130,16 +1123,6 @@ const Settings = ({
> >
{t('openDataManagement')} {t('openDataManagement')}
</Button> </Button>
{/* Reset Button - Always Visible */}
<Button
type="button"
onClick={onReset}
variant="destructive"
className="w-full"
>
{t('resetAllSettings')}
</Button>
</div> </div>
); );
}; };

View File

@@ -26,7 +26,9 @@ import {
TooltipTrigger as UiTooltipTrigger, TooltipTrigger as UiTooltipTrigger,
TooltipContent as UiTooltipContent, TooltipContent as UiTooltipContent,
} from './ui/tooltip'; } from './ui/tooltip';
import { useElementSize } from '../hooks/useElementSize';
// TODO make use of the actual theme colors;some colors are not matching the classes in the comments
// Chart color scheme // Chart color scheme
const CHART_COLORS = { const CHART_COLORS = {
// d-Amphetamine profiles // d-Amphetamine profiles
@@ -41,7 +43,7 @@ const CHART_COLORS = {
// Reference lines // Reference lines
regularPlanDivider: '#22c55e', // green-500 regularPlanDivider: '#22c55e', // green-500
deviationDayDivider: '#9ca3af', // gray-400 deviationDayDivider: '#f59e0b', // yellow-500
therapeuticMin: '#22c55e', // green-500 therapeuticMin: '#22c55e', // green-500
therapeuticMax: '#ef4444', // red-500 therapeuticMax: '#ef4444', // red-500
dayDivider: '#9ca3af', // gray-400 dayDivider: '#9ca3af', // gray-400
@@ -50,12 +52,13 @@ const CHART_COLORS = {
cursor: '#6b7280' // gray-500 cursor: '#6b7280' // gray-500
} as const; } as const;
const SimulationChart = ({ const SimulationChart = React.memo(({
combinedProfile, combinedProfile,
templateProfile, templateProfile,
chartView, chartView,
showDayTimeOnXAxis, showDayTimeOnXAxis,
showDayReferenceLines, showDayReferenceLines,
showIntakeTimeLines,
showTherapeuticRange, showTherapeuticRange,
therapeuticRange, therapeuticRange,
simulationDays, simulationDays,
@@ -67,26 +70,49 @@ const SimulationChart = ({
}: any) => { }: any) => {
const totalHours = (parseInt(simulationDays, 10) || 3) * 24; const totalHours = (parseInt(simulationDays, 10) || 3) * 24;
const dispDays = parseInt(displayedDays, 10) || 2; const dispDays = parseInt(displayedDays, 10) || 2;
const simDays = parseInt(simulationDays, 10) || 3;
// Calculate chart dimensions // Calculate chart dimensions using debounced element size observer
const [containerWidth, setContainerWidth] = React.useState(1000);
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const { width: containerWidth } = useElementSize(containerRef, 150);
// Guard against invalid dimensions during initial render
const yAxisWidth = 80;
const minContainerWidth = yAxisWidth + 100; // Minimum 100px for chart area
const safeContainerWidth = Math.max(containerWidth, minContainerWidth);
// Track current theme for chart styling
const [isDarkTheme, setIsDarkTheme] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
const updateWidth = () => { const checkTheme = () => {
if (containerRef.current) { setIsDarkTheme(document.documentElement.classList.contains('dark'));
setContainerWidth(containerRef.current.clientWidth);
}
}; };
updateWidth(); checkTheme();
window.addEventListener('resize', updateWidth);
return () => window.removeEventListener('resize', updateWidth); // Use MutationObserver to detect theme changes
const observer = new MutationObserver(checkTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
return () => observer.disconnect();
}, []); }, []);
// Use shorter captions on narrow containers to reduce wrapping // Calculate scrollable width using safe container width
const isCompactLabels = containerWidth < 640; // tweakable threshold for mobile const scrollableWidth = safeContainerWidth - yAxisWidth;
// Calculate chart width for scrollable area
const chartWidth = simDays <= dispDays
? scrollableWidth
: Math.ceil((scrollableWidth / dispDays) * simDays);
// Use shorter captions on narrow containers to reduce wrapping
const isCompactLabels = safeContainerWidth < 640; // tweakable threshold for mobile
// Precompute series labels with translations
const seriesLabels = React.useMemo<Record<string, { full: string; short: string; display: string }>>(() => { const seriesLabels = React.useMemo<Record<string, { full: string; short: string; display: string }>>(() => {
const damphFull = t('dAmphetamine'); const damphFull = t('dAmphetamine');
const damphShort = t('dAmphetamineShort', { defaultValue: damphFull }); const damphShort = t('dAmphetamineShort', { defaultValue: damphFull });
@@ -121,16 +147,11 @@ const SimulationChart = ({
}; };
}, [isCompactLabels, t]); }, [isCompactLabels, t]);
const simDays = parseInt(simulationDays, 10) || 3;
// Y-axis takes ~80px, scrollable area gets the rest
const yAxisWidth = 80;
const scrollableWidth = containerWidth - yAxisWidth;
// Dynamically calculate tick interval based on available pixel width // Dynamically calculate tick interval based on available pixel width
// Aim for ~46px per label to avoid overlaps on narrow screens
const xTickInterval = React.useMemo(() => { const xTickInterval = React.useMemo(() => {
const MIN_PX_PER_TICK = 46; // Aim for ~46px per label to avoid overlaps on narrow screens
//const MIN_PX_PER_TICK = 46;
const MIN_PX_PER_TICK = 56; // increased to 56, partially too tight otherwise
const intervals = [1, 2, 3, 4, 6, 8, 12, 24]; const intervals = [1, 2, 3, 4, 6, 8, 12, 24];
const pxPerDay = scrollableWidth / Math.max(1, dispDays); const pxPerDay = scrollableWidth / Math.max(1, dispDays);
@@ -146,8 +167,8 @@ const SimulationChart = ({
return selected ?? 24; return selected ?? 24;
}, [dispDays, scrollableWidth]); }, [dispDays, scrollableWidth]);
// Generate ticks for continuous time axis // Generate x-axis ticks for continuous time axis
const chartTicks = React.useMemo(() => { const xAxisTicks = React.useMemo(() => {
const ticks = []; const ticks = [];
for (let i = 0; i <= totalHours; i += xTickInterval) { for (let i = 0; i <= totalHours; i += xTickInterval) {
ticks.push(i); ticks.push(i);
@@ -155,7 +176,50 @@ const SimulationChart = ({
return ticks; return ticks;
}, [totalHours, xTickInterval]); }, [totalHours, xTickInterval]);
const chartDomain = React.useMemo(() => { // Custom tick renderer for x-axis to handle 12h/24h/continuous formats and dark mode
// Memoized to prevent unnecessary re-renders
const XAxisTick = React.useCallback((props: any) => {
const { x, y, payload } = props;
const h = payload.value as number;
let label: string;
if (showDayTimeOnXAxis === '24h') {
label = `${h % 24}${t('unitHour')}`;
} else if (showDayTimeOnXAxis === '12h') {
const hour12 = h % 24;
if (hour12 === 12) {
label = t('tickNoon');
return (
<text x={x} y={y + 12} textAnchor="middle" fontStyle="italic" fill={isDarkTheme ? '#ccc' : '#666'}>
{label}
</text>
);
}
const displayHour = hour12 === 0 ? 12 : hour12 > 12 ? hour12 - 12 : hour12;
const period = hour12 < 12 ? 'a' : 'p';
label = `${displayHour}${period}`;
} else {
label = `${h}`;
}
return (
<text x={x} y={y + 12} textAnchor="middle" fill={isDarkTheme ? '#ccc' : '#666'}>
{label}
</text>
);
}, [showDayTimeOnXAxis, isDarkTheme, t]);
// Custom tick renderer for y-axis to handle dark mode
// Memoized to prevent unnecessary re-renders
const YAxisTick = React.useCallback((props: any) => {
const { x, y, payload } = props;
return (
<text x={x} y={y + 4} textAnchor="end" fill={isDarkTheme ? '#ccc' : '#666'}>
{payload.value}
</text>
);
}, [isDarkTheme]);
// Calculate Y-axis domain based on data and user settings
const yAxisDomain = React.useMemo(() => {
const numMin = parseFloat(yAxisMin); const numMin = parseFloat(yAxisMin);
const numMax = parseFloat(yAxisMax); const numMax = parseFloat(yAxisMax);
@@ -195,9 +259,9 @@ const chartDomain = React.useMemo(() => {
// User set yAxisMin explicitly // User set yAxisMin explicitly
domainMin = numMin; domainMin = numMin;
} else if (dataMin !== Infinity) { // data exists } else if (dataMin !== Infinity) { // data exists
// Auto mode: add 5% padding below so the line is not flush with x-axis // Auto mode: add 10% padding below so the line is not flush with x-axis
const range = dataMax - dataMin; const range = dataMax - dataMin;
const padding = range * 0.05; const padding = range * 0.1;
domainMin = Math.max(0, dataMin - padding); domainMin = Math.max(0, dataMin - padding);
} else { // no data } else { // no data
domainMin = 0; domainMin = 0;
@@ -206,8 +270,18 @@ const chartDomain = React.useMemo(() => {
// Calculate final domain max // Calculate final domain max
let domainMax: number; let domainMax: number;
if (!isNaN(numMax)) { // max value provided via settings if (!isNaN(numMax)) { // max value provided via settings
// User set yAxisMax explicitly - use it as-is without padding if (dataMax !== -Infinity) {
// User set yAxisMax explicitly
// Add padding to dataMax and use the higher of manual or (dataMax + padding)
const range = dataMax - dataMin;
const padding = range * 0.05;
const dataMaxWithPadding = dataMax + padding;
// Use manual max only if it's higher than dataMax + padding
domainMax = Math.max(numMax, dataMaxWithPadding);
} else {
// No data, use manual max as-is
domainMax = numMax; domainMax = numMax;
}
} else if (dataMax !== -Infinity) { // data exists } else if (dataMax !== -Infinity) { // data exists
// Auto mode: add 5% padding above // Auto mode: add 5% padding above
const range = dataMax - dataMin; const range = dataMax - dataMin;
@@ -218,16 +292,16 @@ const chartDomain = React.useMemo(() => {
} }
return [domainMin, domainMax]; return [domainMin, domainMax];
}, [yAxisMin, yAxisMax, combinedProfile, templateProfile, chartView]); }, [yAxisMin, yAxisMax, combinedProfile, templateProfile, chartView]);
// Check which days have deviations (differ from template) // Check which days have deviations (differ from regular plan)
const daysWithDeviations = React.useMemo(() => { const daysWithDeviations = React.useMemo(() => {
if (!templateProfile || !combinedProfile) return new Set<number>(); if (!templateProfile || !combinedProfile) return new Set<number>();
const deviatingDays = new Set<number>(); const deviatingDays = new Set<number>();
const simDays = parseInt(simulationDays, 10) || 3; const simDays = parseInt(simulationDays, 10) || 3;
// Check each day starting from day 2 (day 1 is always template) // Check each day starting from day 2 (day 1 is always regular plan)
for (let day = 2; day <= simDays; day++) { for (let day = 2; day <= simDays; day++) {
const dayStartHour = (day - 1) * 24; const dayStartHour = (day - 1) * 24;
const dayEndHour = day * 24; const dayEndHour = day * 24;
@@ -270,6 +344,44 @@ const chartDomain = React.useMemo(() => {
} }
}, [days, daysWithDeviations, t]); }, [days, daysWithDeviations, t]);
// Extract all intake times from all days for intake time reference lines
const intakeTimes = React.useMemo(() => {
if (!days || !Array.isArray(days)) return [];
const times: Array<{ hour: number; dayIndex: number; doseIndex: number }> = [];
const simDaysCount = parseInt(simulationDays, 10) || 3;
// Iterate through each simulated day
for (let dayNum = 1; dayNum <= simDaysCount; dayNum++) {
// Determine which schedule to use for this day
let daySchedule;
if (dayNum === 1 || days.length === 1) {
// First day or only one schedule exists: use template/first schedule
daySchedule = days.find(d => d.isTemplate) || days[0];
} else {
// For subsequent days, use the corresponding schedule if it exists, otherwise use template
const scheduleIndex = dayNum - 1;
daySchedule = days[scheduleIndex] || days.find(d => d.isTemplate) || days[0];
}
if (daySchedule && daySchedule.doses) {
daySchedule.doses.forEach((dose: any, doseIdx: number) => {
if (dose.time) {
const [hours, minutes] = dose.time.split(':').map(Number);
const hoursSinceStart = (dayNum - 1) * 24 + hours + minutes / 60;
times.push({
hour: hoursSinceStart,
dayIndex: dayNum,
doseIndex: doseIdx + 1 // 1-based index
});
}
});
}
}
return times;
}, [days, simulationDays]);
// Merge all profiles into a single dataset for proper tooltip synchronization // 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();
@@ -302,11 +414,6 @@ const chartDomain = React.useMemo(() => {
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, daysWithDeviations]); }, [combinedProfile, templateProfile, daysWithDeviations]);
// Calculate chart width for scrollable area
const chartWidth = simDays <= dispDays
? scrollableWidth
: Math.ceil((scrollableWidth / dispDays) * simDays);
// Render legend with tooltips for full names (custom legend renderer) // Render legend with tooltips for full names (custom legend renderer)
const renderLegend = React.useCallback((props: any) => { const renderLegend = React.useCallback((props: any) => {
const { payload } = props; const { payload } = props;
@@ -327,12 +434,12 @@ const chartDomain = React.useMemo(() => {
<UiTooltip> <UiTooltip>
<UiTooltipTrigger asChild> <UiTooltipTrigger asChild>
<span <span
className="px-1 py-0.5 rounded-sm bg-white text-black shadow-sm border border-muted truncate inline-block max-w-[100px]" className="px-1 py-0.5 rounded-sm bg-background text-foreground shadow-sm border border-border truncate inline-block max-w-[100px]"
> >
{labelInfo.display} {labelInfo.display}
</span> </span>
</UiTooltipTrigger> </UiTooltipTrigger>
<UiTooltipContent className="bg-white text-black shadow-md border max-w-xs"> <UiTooltipContent className="bg-background text-foreground shadow-md border border-border max-w-xs">
<span className="font-medium">{labelInfo.full}</span> <span className="font-medium">{labelInfo.full}</span>
</UiTooltipContent> </UiTooltipContent>
</UiTooltip> </UiTooltip>
@@ -343,6 +450,16 @@ const chartDomain = React.useMemo(() => {
); );
}, [seriesLabels]); }, [seriesLabels]);
// Don't render chart if dimensions are invalid (prevents crash during initialization)
if (chartWidth <= 0 || scrollableWidth <= 0) {
return (
<div ref={containerRef} className="flex-grow w-full flex flex-col overflow-y-hidden items-center justify-center text-muted-foreground">
<p>{t('loadingChart', { defaultValue: 'Loading chart...' })}</p>
</div>
);
}
// Render the chart
return ( return (
<div ref={containerRef} className="flex-grow w-full flex flex-col overflow-y-hidden"> <div ref={containerRef} className="flex-grow w-full flex flex-col overflow-y-hidden">
{/* Fixed Legend at top */} {/* Fixed Legend at top */}
@@ -404,56 +521,33 @@ const chartDomain = React.useMemo(() => {
margin={{ top: 0, right: 20, left: 0, bottom: 5 }} margin={{ top: 0, right: 20, left: 0, bottom: 5 }}
syncId="medPlanChart" syncId="medPlanChart"
> >
{/** Custom tick renderer to italicize 'Noon' only in 12h mode */} {/** Custom tick renderer to italicize 'Noon' only in 12h mode */ }
{(() => { <XAxis
const CustomTick = (props: any) => {
const { x, y, payload } = props;
const h = payload.value as number;
let label: string;
if (showDayTimeOnXAxis === '24h') {
label = `${h % 24}${t('unitHour')}`;
} else if (showDayTimeOnXAxis === '12h') {
const hour12 = h % 24;
if (hour12 === 12) {
label = t('tickNoon');
return (
<text x={x} y={y + 12} textAnchor="middle" fontStyle="italic" fill="#666">
{label}
</text>
);
}
const displayHour = hour12 === 0 ? 12 : hour12 > 12 ? hour12 - 12 : hour12;
const period = hour12 < 12 ? 'a' : 'p';
label = `${displayHour}${period}`;
} else {
label = `${h}`;
}
return (
<text x={x} y={y + 12} textAnchor="middle" fill="#666">
{label}
</text>
);
};
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]}
ticks={chartTicks} axisLine={{ stroke: isDarkTheme ? '#ccc' : '#666' }}
tickCount={chartTicks.length} tick={<XAxisTick />}
interval={0} ticks={xAxisTicks}
tick={<CustomTick />} tickCount={xAxisTicks.length}
/>; //tickCount={200}
})()} //interval={1}
allowDecimals={false}
allowDataOverflow={false}
/>
<YAxis <YAxis
yAxisId="concentration" yAxisId="concentration"
// FIXME // FIXME
//label={{ value: t('axisLabelConcentration'), angle: -90, position: 'insideLeft', style: { fontStyle: 'italic', color: '#666' } }} //label={{ value: t('axisLabelConcentration'), angle: -90, position: 'insideLeft', style: { fontStyle: 'italic', color: '#666' } }}
domain={chartDomain as any} domain={yAxisDomain as any}
allowDecimals={false} axisLine={{ stroke: isDarkTheme ? '#ccc' : '#666' }}
tick={<YAxisTick />}
tickCount={20} tickCount={20}
interval={1}
allowDecimals={false}
allowDataOverflow={false}
/> />
<RechartsTooltip <RechartsTooltip
content={({ active, payload, label }) => { content={({ active, payload, label }) => {
@@ -481,9 +575,9 @@ const chartDomain = React.useMemo(() => {
} }
return ( 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' }}> <div className="bg-background border border-border rounded shadow-lg" style={{ margin: 0, padding: 10, whiteSpace: 'nowrap' }}>
<p className="recharts-tooltip-label" style={{ margin: 0 }}>{t('time')}: {timeLabel}</p> <p className="text-foreground font-medium" style={{ margin: 0 }}>{t('time')}: {timeLabel}</p>
<ul className="recharts-tooltip-item-list" style={{ padding: 0, margin: 0 }}> <ul style={{ padding: 0, margin: 0 }}>
{payload.map((entry: any, index: number) => { {payload.map((entry: any, index: number) => {
const labelInfo = seriesLabels[entry.dataKey] || { display: entry.name, full: entry.name }; const labelInfo = seriesLabels[entry.dataKey] || { display: entry.name, full: entry.name };
const isTemplate = entry.dataKey?.toString().includes('template'); const isTemplate = entry.dataKey?.toString().includes('template');
@@ -493,12 +587,12 @@ const chartDomain = React.useMemo(() => {
return ( return (
<li <li
key={`item-${index}`} key={`item-${index}`}
className="recharts-tooltip-item" className="text-foreground"
style={{ display: 'block', paddingTop: 4, paddingBottom: 4, color: entry.color, opacity }} style={{ display: 'block', paddingTop: 4, paddingBottom: 4, color: entry.color, opacity }}
> >
<span className="recharts-tooltip-item-name" title={labelInfo.full}>{labelInfo.display}</span> <span title={labelInfo.full}>{labelInfo.display}</span>
<span className="recharts-tooltip-item-separator">: </span> <span>: </span>
<span className="recharts-tooltip-item-value">{value} {t('unitNgml')}</span> <span>{value} {t('unitNgml')}</span>
</li> </li>
); );
})} })}
@@ -511,9 +605,11 @@ const chartDomain = React.useMemo(() => {
cursor={{ stroke: CHART_COLORS.cursor, strokeWidth: 1, strokeDasharray: '1 1' }} cursor={{ stroke: CHART_COLORS.cursor, strokeWidth: 1, strokeDasharray: '1 1' }}
position={{ y: 0 }} position={{ y: 0 }}
/> />
<CartesianGrid strokeDasharray="1 1" xAxisId="hours" yAxisId="concentration" /> <CartesianGrid strokeDasharray="1 1" xAxisId="hours" yAxisId="concentration"
style={{ stroke: isDarkTheme ? '#666' : '#ccc' }}
/>
{showDayReferenceLines !== false && [...Array(dispDays + 1).keys()].map(day => { {showDayReferenceLines !== false && [...Array(simDays).keys()].map(day => {
// Determine whether to use compact day labels to avoid overlap on narrow screens // Determine whether to use compact day labels to avoid overlap on narrow screens
const pxPerDay = scrollableWidth / Math.max(1, dispDays); const pxPerDay = scrollableWidth / Math.max(1, dispDays);
let label = ""; let label = "";
@@ -544,20 +640,20 @@ const chartDomain = React.useMemo(() => {
/> />
); );
})} })}
{showTherapeuticRange && (chartView === 'damph' || chartView === 'both') && ( {showTherapeuticRange && (chartView === 'damph' || chartView === 'both') && therapeuticRange.min && !isNaN(parseFloat(therapeuticRange.min)) && (
<ReferenceLine <ReferenceLine
y={parseFloat(therapeuticRange.min) || 0} y={parseFloat(therapeuticRange.min)}
label={{ value: t('refLineMin'), position: 'insideTopLeft' }} label={{ value: t('refLineMin'), position: 'insideBottomLeft', style: { fontSize: '0.75rem', fontStyle: 'italic', fill: CHART_COLORS.therapeuticMin } }}
stroke={CHART_COLORS.therapeuticMin} stroke={CHART_COLORS.therapeuticMin}
strokeDasharray="3 3" strokeDasharray="3 3"
xAxisId="hours" xAxisId="hours"
yAxisId="concentration" yAxisId="concentration"
/> />
)} )}
{showTherapeuticRange && (chartView === 'damph' || chartView === 'both') && ( {showTherapeuticRange && (chartView === 'damph' || chartView === 'both') && therapeuticRange.max && !isNaN(parseFloat(therapeuticRange.max)) && (
<ReferenceLine <ReferenceLine
y={parseFloat(therapeuticRange.max) || 0} y={parseFloat(therapeuticRange.max)}
label={{ value: t('refLineMax'), position: 'insideTopLeft' }} label={{ value: t('refLineMax'), position: 'insideTopLeft', style: { fontSize: '0.75rem', fontStyle: 'italic', fill: CHART_COLORS.therapeuticMax } }}
stroke={CHART_COLORS.therapeuticMax} stroke={CHART_COLORS.therapeuticMax}
strokeDasharray="3 3" strokeDasharray="3 3"
xAxisId="hours" xAxisId="hours"
@@ -565,6 +661,43 @@ const chartDomain = React.useMemo(() => {
/> />
)} )}
{showIntakeTimeLines && intakeTimes.map((intake, idx) => {
// Determine label position offset if day lines are also shown
const labelOffsetY = showDayReferenceLines !== false ? 20 : 5; // More spacing when day lines are shown
return (
<ReferenceLine
key={`intake-${idx}`}
x={intake.hour}
label={(props: any) => {
const { viewBox } = props;
// Position at top-right of the reference line with proper offsets
// x: subtract 5px from right edge to create gap between line and text
// y: add offset + ~12px (font size) since y is the text baseline, not top
const x = viewBox.x + viewBox.width - 5;
const y = viewBox.y + labelOffsetY + 12; // 12px ≈ 0.75rem font size
return (
<text
x={x}
y={y}
textAnchor="end"
fontSize="0.75rem"
fontStyle="italic"
fill="#a0a0a0"
>
{intake.doseIndex}
</text>
);
}}
stroke="#c0c0c0"
strokeDasharray="3 3"
xAxisId="hours"
yAxisId="concentration"
/>
);
})}
{[...Array(parseInt(simulationDays, 10) || 3).keys()].map(day => ( {[...Array(parseInt(simulationDays, 10) || 3).keys()].map(day => (
day > 0 && ( day > 0 && (
<ReferenceLine <ReferenceLine
@@ -642,6 +775,27 @@ const chartDomain = React.useMemo(() => {
</div> </div>
</div> </div>
); );
}; }, (prevProps, nextProps) => {
// Custom comparison function to prevent unnecessary re-renders
// Only re-render if relevant props actually changed
return (
prevProps.combinedProfile === nextProps.combinedProfile &&
prevProps.templateProfile === nextProps.templateProfile &&
prevProps.chartView === nextProps.chartView &&
prevProps.showDayTimeOnXAxis === nextProps.showDayTimeOnXAxis &&
prevProps.showDayReferenceLines === nextProps.showDayReferenceLines &&
prevProps.showIntakeTimeLines === nextProps.showIntakeTimeLines &&
prevProps.showTherapeuticRange === nextProps.showTherapeuticRange &&
prevProps.therapeuticRange?.min === nextProps.therapeuticRange?.min &&
prevProps.therapeuticRange?.max === nextProps.therapeuticRange?.max &&
prevProps.simulationDays === nextProps.simulationDays &&
prevProps.displayedDays === nextProps.displayedDays &&
prevProps.yAxisMin === nextProps.yAxisMin &&
prevProps.yAxisMax === nextProps.yAxisMax &&
prevProps.days === nextProps.days
);
});
SimulationChart.displayName = 'SimulationChart';
export default SimulationChart; export default SimulationChart;

View File

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

View File

@@ -34,7 +34,7 @@ const CollapsibleCardHeader: React.FC<CollapsibleCardHeaderProps> = ({
return ( return (
<CardHeader className={cn('pb-3', className)}> <CardHeader className={cn('pb-3', className)}>
<div className="flex items-center justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap flex-1"> <div className="flex items-center gap-2 flex-wrap flex-1">
<button <button
type="button" type="button"
@@ -48,7 +48,7 @@ const CollapsibleCardHeader: React.FC<CollapsibleCardHeaderProps> = ({
</CardTitle> </CardTitle>
{isCollapsed ? <ChevronDown className="h-5 w-5 flex-shrink-0" /> : <ChevronUp className="h-5 w-5 flex-shrink-0" />} {isCollapsed ? <ChevronDown className="h-5 w-5 flex-shrink-0" /> : <ChevronUp className="h-5 w-5 flex-shrink-0" />}
</button> </button>
{children} {children && <div className="flex items-center gap-2 flex-nowrap">{children}</div>}
</div> </div>
{rightSection && <div className="flex items-center gap-2">{rightSection}</div>} {rightSection && <div className="flex items-center gap-2">{rightSection}</div>}
</div> </div>

View File

@@ -9,7 +9,7 @@
*/ */
import * as React from "react" import * as React from "react"
import { Minus, Plus, X } from "lucide-react" import { Minus, Plus, RotateCcw } from "lucide-react"
import { Button } from "./button" import { Button } from "./button"
import { IconButtonWithTooltip } from "./icon-button-with-tooltip" import { IconButtonWithTooltip } from "./icon-button-with-tooltip"
import { Input } from "./input" import { Input } from "./input"
@@ -25,12 +25,14 @@ interface NumericInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
unit?: string unit?: string
align?: 'left' | 'center' | 'right' align?: 'left' | 'center' | 'right'
allowEmpty?: boolean allowEmpty?: boolean
clearButton?: boolean showResetButton?: boolean
defaultValue?: number | string
error?: boolean error?: boolean
warning?: boolean warning?: boolean
required?: boolean required?: boolean
errorMessage?: string errorMessage?: React.ReactNode
warningMessage?: string warningMessage?: React.ReactNode
inputWidth?: string // Custom width for the input field (e.g., 'w-16', 'w-20')
} }
const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>( const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
@@ -43,12 +45,14 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
unit, unit,
align = 'right', align = 'right',
allowEmpty = false, allowEmpty = false,
clearButton = false, showResetButton = false,
defaultValue,
error = false, error = false,
warning = false, warning = false,
required = false, required = false,
errorMessage = 'Time is required', errorMessage = 'Value is required',
warningMessage, warningMessage,
inputWidth = 'w-20', // Default width
className, className,
...props ...props
}, ref) => { }, ref) => {
@@ -74,7 +78,7 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
}, [isInvalid, touched]) }, [isInvalid, touched])
// Determine decimal places based on increment // Determine decimal places based on increment
const getDecimalPlaces = () => { const getDecimalPlaces = () => {
const inc = String(increment || '1') const inc = String(increment || '1').replace(',', '.')
const decimalIndex = inc.indexOf('.') const decimalIndex = inc.indexOf('.')
if (decimalIndex === -1) return 0 if (decimalIndex === -1) return 0
return inc.length - decimalIndex - 1 return inc.length - decimalIndex - 1
@@ -97,7 +101,25 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
numValue = 0 numValue = 0
} }
numValue += direction * numIncrement // Round the current value to avoid floating-point precision issues in comparisons
const decimalPlaces = getDecimalPlaces()
numValue = Math.round(numValue * Math.pow(10, decimalPlaces)) / Math.pow(10, decimalPlaces)
// Snap to nearest increment first, then move one increment in the desired direction
if (direction > 0) {
// For increment: round up to next increment value, ensuring at least one increment is added
const steps = Math.round((numValue / numIncrement) * 1e10) / 1e10 // Avoid floating-point errors in division
const snappedSteps = Math.ceil(steps)
const snapped = Math.round((snappedSteps * numIncrement) * Math.pow(10, decimalPlaces)) / Math.pow(10, decimalPlaces)
numValue = snapped > numValue ? snapped : Math.round(((snappedSteps + 1) * numIncrement) * Math.pow(10, decimalPlaces)) / Math.pow(10, decimalPlaces)
} else {
// For decrement: round down to previous increment value, ensuring at least one increment is subtracted
const steps = Math.round((numValue / numIncrement) * 1e10) / 1e10 // Avoid floating-point errors in division
const snappedSteps = Math.floor(steps)
const snapped = Math.round((snappedSteps * numIncrement) * Math.pow(10, decimalPlaces)) / Math.pow(10, decimalPlaces)
numValue = snapped < numValue ? snapped : Math.round(((snappedSteps - 1) * numIncrement) * Math.pow(10, decimalPlaces)) / Math.pow(10, decimalPlaces)
}
numValue = Math.max(min, numValue) numValue = Math.max(min, numValue)
numValue = Math.min(max, numValue) numValue = Math.min(max, numValue)
onChange(formatValue(numValue)) onChange(formatValue(numValue))
@@ -106,12 +128,27 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
// Check if we're at min/max before allowing arrow key navigation
const numValue = Number(value)
const hasValidNumber = !isNaN(numValue) && value !== ''
if (e.key === 'ArrowDown' && hasValidNumber && numValue <= min) {
return // Don't decrement if at min
}
if (e.key === 'ArrowUp' && hasValidNumber && numValue >= max) {
return // Don't increment if at max
}
updateValue(e.key === 'ArrowUp' ? 1 : -1) updateValue(e.key === 'ArrowUp' ? 1 : -1)
} }
} }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value let val = e.target.value
// Replace comma with period to support European decimal separator
val = val.replace(',', '.')
// Allow any valid numeric input during typing (including partial values like "1", "12.", etc.)
if (val === '' || /^-?\d*\.?\d*$/.test(val)) { if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
onChange(val) onChange(val)
} }
@@ -131,7 +168,11 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
} }
if (inputValue !== '' && !isNaN(Number(inputValue))) { if (inputValue !== '' && !isNaN(Number(inputValue))) {
onChange(formatValue(inputValue)) let numValue = Number(inputValue)
// Enforce min/max constraints
numValue = Math.max(min, numValue)
numValue = Math.min(max, numValue)
onChange(formatValue(numValue))
} }
} }
@@ -150,23 +191,15 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
} }
} }
// Determine if buttons should be disabled based on current value and min/max
const numValue = Number(value)
const hasValidNumber = !isNaN(numValue) && value !== ''
const isAtMin = hasValidNumber && numValue <= min
const isAtMax = hasValidNumber && numValue >= max
return ( return (
<div ref={containerRef} className={cn("relative flex items-center gap-2", className)}> <div ref={containerRef} className={cn("relative flex items-center gap-2", className)}>
<div className="flex items-center"> <div className="flex items-center">
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"h-9 w-9 rounded-r-none border-r-0",
hasError && "border-destructive",
hasWarning && !hasError && "border-yellow-500"
)}
onClick={() => updateValue(-1)}
tabIndex={-1}
>
<Minus className="h-4 w-4" />
</Button>
<Input <Input
ref={ref} ref={ref}
type="text" type="text"
@@ -176,54 +209,62 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
onFocus={handleFocus} onFocus={handleFocus}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={cn( className={cn(
"w-20 h-9 z-20", inputWidth, "h-9 z-10",
"rounded-none", "rounded-r rounded-r-none",
getAlignmentClass(), getAlignmentClass(),
hasError && "border-destructive focus-visible:ring-destructive", hasError && "error-border focus-visible:ring-destructive",
hasWarning && !hasError && "border-yellow-500 focus-visible:ring-yellow-500" hasWarning && !hasError && "warning-border focus-visible:ring-amber-500"
)} )}
{...props} {...props}
/> />
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 rounded-l-none rounded-r-none border-l-0"
onClick={() => updateValue(-1)}
disabled={isAtMin}
tabIndex={-1}
>
<Minus className="h-4 w-4" />
</Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
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", showResetButton ? "rounded-l-none rounded-r-none border-x-0" : "rounded-l-none border-l-0",
hasError && "border-destructive", //hasError && "error-border",
hasWarning && !hasError && "border-yellow-500" //hasWarning && !hasError && "warning-border"
)} )}
onClick={() => updateValue(1)} onClick={() => updateValue(1)}
disabled={isAtMax}
tabIndex={-1} tabIndex={-1}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
{clearButton && allowEmpty && ( {showResetButton && (
<IconButtonWithTooltip <IconButtonWithTooltip
type="button" type="button"
icon={<X className="h-4 w-4" />} icon={<RotateCcw className="h-4 w-4" />}
tooltip={t('buttonClear')} tooltip={t('buttonResetToDefault')}
variant="outline" variant="outline"
size="icon" size="icon"
className={cn( className="h-9 w-9 rounded-l-none"
"h-9 w-9 rounded-l-none", onClick={() => onChange(String(defaultValue ?? ''))}
hasError && "border-destructive",
hasWarning && !hasError && "border-yellow-500"
)}
onClick={() => onChange('')}
tabIndex={-1} tabIndex={-1}
/> />
)} )}
</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 && isFocused && errorMessage && ( {hasError && isFocused && errorMessage && (
<div className="absolute top-full left-0 mt-1 z-25 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-20 w-80 error-bubble text-xs p-2 rounded-md shadow-lg">
{errorMessage} {errorMessage}
</div> </div>
)} )}
{hasWarning && isFocused && warningMessage && ( {hasWarning && isFocused && warningMessage && (
<div className="absolute top-full left-0 mt-1 z-25 w-48 bg-yellow-500 text-white text-xs p-2 rounded-md shadow-lg"> <div className="absolute top-full left-0 mt-1 z-20 w-80 warning-bubble text-xs p-2 rounded-md shadow-lg">
{warningMessage} {warningMessage}
</div> </div>
)} )}

View File

@@ -0,0 +1,65 @@
/**
* Custom Form Component: Select with Reset Button
*
* A select/combobox field with an optional reset to default button.
* Built on top of shadcn/ui Select component.
*
* @author Andreas Weyer
* @license MIT
*/
import * as React from "react"
import { RotateCcw } from "lucide-react"
import { IconButtonWithTooltip } from "./icon-button-with-tooltip"
import { Select, SelectTrigger, SelectValue, SelectContent } from "./select"
import { cn } from "../../lib/utils"
import { useTranslation } from "react-i18next"
interface FormSelectProps {
value: string
onValueChange: (value: string) => void
showResetButton?: boolean
defaultValue?: string
children: React.ReactNode
triggerClassName?: string
placeholder?: string
}
export const FormSelect: React.FC<FormSelectProps> = ({
value,
onValueChange,
showResetButton = false,
defaultValue,
children,
triggerClassName,
placeholder,
}) => {
const { t } = useTranslation()
return (
<div className="flex items-center gap-0">
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger className={cn(
showResetButton && "rounded-r-none border-r-0 z-10",
"bg-background",
triggerClassName
)}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
{children}
</Select>
{showResetButton && (
<IconButtonWithTooltip
type="button"
icon={<RotateCcw className="h-4 w-4" />}
tooltip={t('buttonResetToDefault')}
variant="outline"
size="icon"
className="h-9 w-9 rounded-l-none border-l-0"
onClick={() => onValueChange(defaultValue || '')}
tabIndex={-1}
/>
)}
</div>
)
}

View File

@@ -16,22 +16,24 @@ import { Popover, PopoverContent, PopoverTrigger } from "./popover"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
interface TimeInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> { interface TimeInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'onBlur'> {
value: string value: string
onChange: (value: string) => void onChange: (value: string) => void
onBlur?: () => void
unit?: string unit?: string
align?: 'left' | 'center' | 'right' align?: 'left' | 'center' | 'right'
error?: boolean error?: boolean
warning?: boolean warning?: boolean
required?: boolean required?: boolean
errorMessage?: string errorMessage?: React.ReactNode
warningMessage?: string warningMessage?: React.ReactNode
} }
const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>( const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
({ ({
value, value,
onChange, onChange,
onBlur,
unit, unit,
align = 'center', align = 'center',
error = false, error = false,
@@ -51,6 +53,9 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
const [isFocused, setIsFocused] = React.useState(false) const [isFocused, setIsFocused] = React.useState(false)
const containerRef = React.useRef<HTMLDivElement>(null) const containerRef = React.useRef<HTMLDivElement>(null)
// Store original value when opening picker (for cancel/revert)
const [originalValue, setOriginalValue] = React.useState<string>('')
// Current committed value parsed from prop // Current committed value parsed from prop
const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number) const [pickerHours, pickerMinutes] = (value || "00:00").split(':').map(Number)
@@ -86,6 +91,8 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
if (inputValue === '') { if (inputValue === '') {
// Update parent with empty value so validation works // Update parent with empty value so validation works
onChange('') onChange('')
// Call optional onBlur callback after internal handling
onBlur?.()
return return
} }
@@ -108,6 +115,9 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
setDisplayValue(formattedTime) setDisplayValue(formattedTime)
onChange(formattedTime) onChange(formattedTime)
// Call optional onBlur callback after internal handling
onBlur?.()
} }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -128,26 +138,45 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
const handlePickerOpen = (open: boolean) => { const handlePickerOpen = (open: boolean) => {
setIsPickerOpen(open) setIsPickerOpen(open)
if (open) { if (open) {
// Reset staging when opening picker // Save original value for cancel/revert and reset staging
setOriginalValue(value)
setStagedHour(null) setStagedHour(null)
setStagedMinute(null) setStagedMinute(null)
} else if (!open && originalValue) {
// Closing without explicit Apply - revert to original value
onChange(originalValue)
setOriginalValue('')
} }
} }
const handleHourClick = (hour: number) => { const handleHourClick = (hour: number) => {
setStagedHour(hour) setStagedHour(hour)
// Update simulation immediately with new hour (keeping current or staged minute)
const finalMinute = stagedMinute !== null ? stagedMinute : pickerMinutes
const formattedTime = `${String(hour).padStart(2, '0')}:${String(finalMinute).padStart(2, '0')}`
onChange(formattedTime)
} }
const handleMinuteClick = (minute: number) => { const handleMinuteClick = (minute: number) => {
setStagedMinute(minute) setStagedMinute(minute)
// Update simulation immediately with new minute (keeping current or staged hour)
const finalHour = stagedHour !== null ? stagedHour : pickerHours
const formattedTime = `${String(finalHour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
onChange(formattedTime)
} }
const handleApply = () => { const handleApply = () => {
// Use staged values if selected, otherwise keep current values // Commit the current value (already updated in real-time) and close
const finalHour = stagedHour !== null ? stagedHour : pickerHours setOriginalValue('') // Clear original so revert doesn't happen on close
const finalMinute = stagedMinute !== null ? stagedMinute : pickerMinutes setIsPickerOpen(false)
const formattedTime = `${String(finalHour).padStart(2, '0')}:${String(finalMinute).padStart(2, '0')}` // Call optional onBlur callback after applying picker changes
onChange(formattedTime) onBlur?.()
}
const handleCancel = () => {
// Revert to original value
onChange(originalValue)
setOriginalValue('')
setIsPickerOpen(false) setIsPickerOpen(false)
} }
@@ -179,8 +208,8 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
"w-20 h-9 z-20", "w-20 h-9 z-20",
"rounded-r-none", "rounded-r-none",
getAlignmentClass(), getAlignmentClass(),
hasError && "border-destructive focus-visible:ring-destructive", hasError && "error-border focus-visible:ring-destructive",
hasWarning && !hasError && "border-yellow-500 focus-visible:ring-yellow-500" hasWarning && !hasError && "warning-border focus-visible:ring-amber-500"
)} )}
{...props} {...props}
/> />
@@ -199,12 +228,12 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-3 bg-popover shadow-md border"> <PopoverContent className="w-auto p-2 bg-popover shadow-md border">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-2">
<div className="flex gap-2"> <div className="flex gap-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-0 border rounded-md bg-transparent">
<div className="text-xs font-medium text-center mb-1">{t('timePickerHour')}</div> <div className="text-xs font-bold text-center mt-1">{t('timePickerHour')}</div>
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto"> <div className="grid grid-cols-6 gap-0.5 p-1 max-h-70 overflow-y-auto">
{Array.from({ length: 24 }, (_, i) => { {Array.from({ length: 24 }, (_, i) => {
const isCurrentValue = pickerHours === i && stagedHour === null const isCurrentValue = pickerHours === i && stagedHour === null
const isStaged = stagedHour === i const isStaged = stagedHour === i
@@ -214,7 +243,8 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
type="button" type="button"
variant={isStaged ? "default" : isCurrentValue ? "secondary" : "outline"} variant={isStaged ? "default" : isCurrentValue ? "secondary" : "outline"}
size="sm" size="sm"
className="h-8 w-10" //className={cn("h-8 text-sm", i === 0 ? "col-span-3": "w-10")}
className="h-8 w-10 text-sm"
onClick={() => handleHourClick(i)} onClick={() => handleHourClick(i)}
> >
{String(i).padStart(2, '0')} {String(i).padStart(2, '0')}
@@ -223,9 +253,9 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
})} })}
</div> </div>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-0 border rounded-md bg-transparent">
<div className="text-xs font-medium text-center mb-1">{t('timePickerMinute')}</div> <div className="text-xs font-bold text-center mt-1">{t('timePickerMinute')}</div>
<div className="grid grid-cols-4 gap-1 max-h-60 overflow-y-auto"> <div className="grid grid-cols-3 gap-0.5 p-1 max-h-70 overflow-y-auto">
{Array.from({ length: 12 }, (_, i) => i * 5).map(minute => { {Array.from({ length: 12 }, (_, i) => i * 5).map(minute => {
const isCurrentValue = pickerMinutes === minute && stagedMinute === null const isCurrentValue = pickerMinutes === minute && stagedMinute === null
const isStaged = stagedMinute === minute const isStaged = stagedMinute === minute
@@ -235,7 +265,7 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
type="button" type="button"
variant={isStaged ? "default" : isCurrentValue ? "secondary" : "outline"} variant={isStaged ? "default" : isCurrentValue ? "secondary" : "outline"}
size="sm" size="sm"
className="h-8 w-10" className="h-8 w-10 text-sm"
onClick={() => handleMinuteClick(minute)} onClick={() => handleMinuteClick(minute)}
> >
{String(minute).padStart(2, '0')} {String(minute).padStart(2, '0')}
@@ -245,7 +275,15 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={handleCancel}
>
{t('timePickerCancel')}
</Button>
<Button <Button
type="button" type="button"
size="sm" size="sm"
@@ -261,12 +299,12 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
</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 && isFocused && 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-80 error-bubble text-xs p-2 rounded-md shadow-lg">
{errorMessage} {errorMessage}
</div> </div>
)} )}
{hasWarning && isFocused && warningMessage && ( {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"> <div className="absolute top-full left-0 mt-1 z-50 w-80 warning-bubble text-xs p-2 rounded-md shadow-lg">
{warningMessage} {warningMessage}
</div> </div>
)} )}

View File

@@ -26,11 +26,15 @@ const versionInfo = versionJsonDefault && Object.keys(versionJsonDefault).length
gitDate: 'unknown', gitDate: 'unknown',
}; };
export const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v8'; // Incremented for ageGroup + renalFunction fields export const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v10'; // Incremented for profile-based schedule management
export const MAX_PROFILES = 20; // Maximum number of schedule profiles allowed
export const PROJECT_REPOSITORY_URL = 'https://git.11001001.org/cbaoth/med-plan-assistant'; export const PROJECT_REPOSITORY_URL = 'https://git.11001001.org/cbaoth/med-plan-assistant';
export const APP_VERSION = versionInfo.version; export const APP_VERSION = versionInfo.version;
export const BUILD_INFO = versionInfo; export const BUILD_INFO = versionInfo;
// UI Configuration
export const MAX_DOSES_PER_DAY = 6; // Maximum number of doses allowed per day
// Pharmacokinetic Constants (from research literature) // Pharmacokinetic Constants (from research literature)
// MW ratio: 135.21 (d-amphetamine) / 455.60 (LDX dimesylate) = 0.29677 // MW ratio: 135.21 (d-amphetamine) / 455.60 (LDX dimesylate) = 0.29677
export const LDX_TO_DAMPH_SALT_FACTOR = 0.29677; export const LDX_TO_DAMPH_SALT_FACTOR = 0.29677;
@@ -39,10 +43,9 @@ export const DEFAULT_F_ORAL = 0.96;
// Type definitions // Type definitions
export interface AdvancedSettings { export interface AdvancedSettings {
standardVd: { preset: 'adult' | 'child' | 'custom'; customValue: string }; // Volume of distribution (L) standardVd: { preset: 'adult' | 'child' | 'custom' | 'weight-based'; customValue: string; bodyWeight: string }; // Volume of distribution (L)
weightBasedVd: { enabled: boolean; bodyWeight: string }; // kg
foodEffect: { enabled: boolean; tmaxDelay: string }; // hours foodEffect: { enabled: boolean; tmaxDelay: string }; // hours
urinePh: { enabled: boolean; phTendency: string }; // 5.5-8.0 range urinePh: { mode: 'normal' | 'acidic' | 'alkaline' }; // pH effect on elimination
fOral: string; // bioavailability fraction fOral: string; // bioavailability fraction
steadyStateDays: string; // days of medication history to simulate steadyStateDays: string; // days of medication history to simulate
// Age-specific pharmacokinetics (Research Section 5.2) // Age-specific pharmacokinetics (Research Section 5.2)
@@ -78,6 +81,14 @@ export interface DayGroup {
doses: DayDose[]; doses: DayDose[];
} }
export interface ScheduleProfile {
id: string;
name: string;
days: DayGroup[];
createdAt: string;
modifiedAt: string;
}
export interface SteadyStateConfig { export interface SteadyStateConfig {
daysOnMedication: string; daysOnMedication: string;
} }
@@ -96,6 +107,7 @@ export interface UiSettings {
simulationDays: string; simulationDays: string;
displayedDays: string; displayedDays: string;
showDayReferenceLines?: boolean; showDayReferenceLines?: boolean;
showIntakeTimeLines?: boolean;
showTherapeuticRange?: boolean; showTherapeuticRange?: boolean;
steadyStateDaysEnabled?: boolean; steadyStateDaysEnabled?: boolean;
stickyChart: boolean; stickyChart: boolean;
@@ -104,7 +116,9 @@ export interface UiSettings {
export interface AppState { export interface AppState {
pkParams: PkParams; pkParams: PkParams;
days: DayGroup[]; days: DayGroup[]; // Kept for backwards compatibility during migration
profiles: ScheduleProfile[];
activeProfileId: string;
steadyStateConfig: SteadyStateConfig; steadyStateConfig: SteadyStateConfig;
therapeuticRange: TherapeuticRange; therapeuticRange: TherapeuticRange;
doseIncrement: string; doseIncrement: string;
@@ -130,7 +144,61 @@ export interface ConcentrationPoint {
} }
// Default application state // Default application state
export const getDefaultState = (): AppState => ({ export const getDefaultState = (): AppState => {
const now = new Date().toISOString();
const profiles: ScheduleProfile[] = [
{
id: 'profile-default-1',
name: 'Single Morning Dose',
createdAt: now,
modifiedAt: now,
days: [
{
id: 'day-template',
isTemplate: true,
doses: [
{ id: 'dose-1', time: '08:00', ldx: '30' }
]
}
]
},
{
id: 'profile-default-2',
name: 'Twice Daily',
createdAt: now,
modifiedAt: now,
days: [
{
id: 'day-template',
isTemplate: true,
doses: [
{ id: 'dose-1', time: '08:00', ldx: '20' },
{ id: 'dose-2', time: '14:00', ldx: '20' }
]
}
]
},
{
id: 'profile-default-3',
name: 'Three Times Daily',
createdAt: now,
modifiedAt: now,
days: [
{
id: 'day-template',
isTemplate: true,
doses: [
{ id: 'dose-1', time: '08:00', ldx: '20' },
{ id: 'dose-2', time: '14:00', ldx: '20' },
{ id: 'dose-3', time: '20:00', ldx: '20' }
]
}
]
}
];
return {
pkParams: { pkParams: {
damph: { halfLife: '11' }, damph: { halfLife: '11' },
ldx: { ldx: {
@@ -138,28 +206,18 @@ export const getDefaultState = (): AppState => ({
absorptionHalfLife: '0.7' // Updated from 0.9 for better ~1h Tmax of prodrug absorptionHalfLife: '0.7' // Updated from 0.9 for better ~1h Tmax of prodrug
}, },
advanced: { advanced: {
standardVd: { preset: 'adult', customValue: '377' }, // Adult: 377L (Roberts 2015), Child: ~150-200L standardVd: { preset: 'adult', customValue: '377', bodyWeight: '70' }, // Adult: 377L (Roberts 2015), Child: ~150-200L, Weight-based: ~5.4 L/kg
weightBasedVd: { enabled: false, bodyWeight: '70' }, // kg, adult average
foodEffect: { enabled: false, tmaxDelay: '1.0' }, // hours delay foodEffect: { enabled: false, tmaxDelay: '1.0' }, // hours delay
urinePh: { enabled: false, phTendency: '6.0' }, // pH scale (5.5-8.0) urinePh: { mode: 'normal' }, // 'normal' (6-7.5), 'acidic' (<6), 'alkaline' (>7.5)
fOral: String(DEFAULT_F_ORAL), // 0.96 bioavailability fOral: String(DEFAULT_F_ORAL), // 0.96 bioavailability
steadyStateDays: '7' // days of prior medication history steadyStateDays: '7' // days of prior medication history
} }
}, },
days: [ days: profiles[0].days, // For backwards compatibility, use first profile's days
{ profiles,
id: 'day-template', activeProfileId: profiles[0].id,
isTemplate: true,
doses: [
{ id: 'dose-1', time: '06:30', ldx: '20' },
{ id: 'dose-2', time: '12:30', ldx: '10' },
{ id: 'dose-3', time: '17:30', ldx: '10' },
{ id: 'dose-4', time: '22:00', ldx: '7.5' },
]
}
],
steadyStateConfig: { daysOnMedication: '7' }, // kept for backwards compatibility, now sourced from pkParams.advanced steadyStateConfig: { daysOnMedication: '7' }, // kept for backwards compatibility, now sourced from pkParams.advanced
therapeuticRange: { min: '', max: '' }, // Empty by default - users should personalize based on their response therapeuticRange: { min: '', max: '' }, // users should personalize based on their response
doseIncrement: '2.5', doseIncrement: '2.5',
uiSettings: { uiSettings: {
showDayTimeOnXAxis: '24h', showDayTimeOnXAxis: '24h',
@@ -170,8 +228,10 @@ export const getDefaultState = (): AppState => ({
simulationDays: '5', simulationDays: '5',
displayedDays: '2', displayedDays: '2',
showTherapeuticRange: false, showTherapeuticRange: false,
showIntakeTimeLines: false,
steadyStateDaysEnabled: true, steadyStateDaysEnabled: true,
stickyChart: false, stickyChart: false,
theme: 'system', theme: 'system',
} }
}); };
};

View File

@@ -10,7 +10,7 @@
*/ */
import React from 'react'; import React from 'react';
import { LOCAL_STORAGE_KEY, getDefaultState, type AppState, type DayGroup, type DayDose } from '../constants/defaults'; import { LOCAL_STORAGE_KEY, getDefaultState, MAX_DOSES_PER_DAY, MAX_PROFILES, type AppState, type DayGroup, type DayDose, type ScheduleProfile } from '../constants/defaults';
export const useAppState = () => { export const useAppState = () => {
const [appState, setAppState] = React.useState<AppState>(getDefaultState); const [appState, setAppState] = React.useState<AppState>(getDefaultState);
@@ -29,11 +29,110 @@ export const useAppState = () => {
migratedUiSettings.showDayTimeOnXAxis = migratedUiSettings.showDayTimeOnXAxis ? '24h' : 'continuous'; migratedUiSettings.showDayTimeOnXAxis = migratedUiSettings.showDayTimeOnXAxis ? '24h' : 'continuous';
} }
// Migrate urinePh from old {enabled, phTendency} to new {mode} structure
let migratedPkParams = {...defaults.pkParams, ...parsedState.pkParams};
if (migratedPkParams.advanced) {
const oldUrinePh = migratedPkParams.advanced.urinePh as any;
if (oldUrinePh && typeof oldUrinePh === 'object' && 'enabled' in oldUrinePh) {
// Old format detected: {enabled: boolean, phTendency: string}
if (!oldUrinePh.enabled) {
migratedPkParams.advanced.urinePh = { mode: 'normal' };
} else {
const phValue = parseFloat(oldUrinePh.phTendency);
if (!isNaN(phValue)) {
if (phValue < 6.0) {
migratedPkParams.advanced.urinePh = { mode: 'acidic' };
} else if (phValue > 7.5) {
migratedPkParams.advanced.urinePh = { mode: 'alkaline' };
} else {
migratedPkParams.advanced.urinePh = { mode: 'normal' };
}
} else {
migratedPkParams.advanced.urinePh = { mode: 'normal' };
}
}
}
// Migrate weightBasedVd from old {enabled, bodyWeight} to new standardVd structure
const oldWeightBasedVd = (migratedPkParams.advanced as any).weightBasedVd;
if (oldWeightBasedVd && typeof oldWeightBasedVd === 'object' && 'enabled' in oldWeightBasedVd) {
// Old format detected: {enabled: boolean, bodyWeight: string}
if (oldWeightBasedVd.enabled) {
// Convert to new weight-based preset
migratedPkParams.advanced.standardVd = {
preset: 'weight-based',
customValue: migratedPkParams.advanced.standardVd?.customValue || '377',
bodyWeight: oldWeightBasedVd.bodyWeight || '70'
};
} else {
// Keep existing standardVd, but ensure bodyWeight is present
if (!migratedPkParams.advanced.standardVd?.bodyWeight) {
migratedPkParams.advanced.standardVd = {
...migratedPkParams.advanced.standardVd,
bodyWeight: oldWeightBasedVd.bodyWeight || '70'
};
}
}
// Remove old weightBasedVd property
delete (migratedPkParams.advanced as any).weightBasedVd;
}
// Ensure bodyWeight exists in standardVd (for new installations or old formats)
if (!migratedPkParams.advanced.standardVd?.bodyWeight) {
migratedPkParams.advanced.standardVd = {
...migratedPkParams.advanced.standardVd,
bodyWeight: '70'
};
}
}
// Validate numeric fields and replace empty/invalid values with defaults
const validateNumericField = (value: any, defaultValue: any): any => {
if (value === '' || value === null || value === undefined || isNaN(Number(value))) {
return defaultValue;
}
return value;
};
// Migrate from old days-only format to profile-based format
let migratedProfiles: ScheduleProfile[] = defaults.profiles;
let migratedActiveProfileId: string = defaults.activeProfileId;
let migratedDays: DayGroup[] = defaults.days;
if (parsedState.profiles && Array.isArray(parsedState.profiles)) {
// New format with profiles
migratedProfiles = parsedState.profiles;
migratedActiveProfileId = parsedState.activeProfileId || parsedState.profiles[0]?.id || defaults.activeProfileId;
// Validate activeProfileId exists in profiles
const activeProfile = migratedProfiles.find(p => p.id === migratedActiveProfileId);
if (!activeProfile && migratedProfiles.length > 0) {
migratedActiveProfileId = migratedProfiles[0].id;
}
// Set days from active profile
migratedDays = activeProfile?.days || defaults.days;
} else if (parsedState.days) {
// Old format: migrate days to default profile
const now = new Date().toISOString();
migratedProfiles = [{
id: `profile-migrated-${Date.now()}`,
name: 'Default',
days: parsedState.days,
createdAt: now,
modifiedAt: now
}];
migratedActiveProfileId = migratedProfiles[0].id;
migratedDays = parsedState.days;
}
setAppState({ setAppState({
...defaults, ...defaults,
...parsedState, ...parsedState,
pkParams: {...defaults.pkParams, ...parsedState.pkParams}, pkParams: migratedPkParams,
days: parsedState.days || defaults.days, days: migratedDays,
profiles: migratedProfiles,
activeProfileId: migratedActiveProfileId,
uiSettings: migratedUiSettings, uiSettings: migratedUiSettings,
}); });
} }
@@ -49,6 +148,8 @@ export const useAppState = () => {
const stateToSave = { const stateToSave = {
pkParams: appState.pkParams, pkParams: appState.pkParams,
days: appState.days, days: appState.days,
profiles: appState.profiles,
activeProfileId: appState.activeProfileId,
steadyStateConfig: appState.steadyStateConfig, steadyStateConfig: appState.steadyStateConfig,
therapeuticRange: appState.therapeuticRange, therapeuticRange: appState.therapeuticRange,
doseIncrement: appState.doseIncrement, doseIncrement: appState.doseIncrement,
@@ -153,13 +254,34 @@ export const useAppState = () => {
...prev, ...prev,
days: prev.days.map(day => { days: prev.days.map(day => {
if (day.id !== dayId) return day; if (day.id !== dayId) return day;
if (day.doses.length >= 5) return day; // Max 5 doses per day if (day.doses.length >= MAX_DOSES_PER_DAY) return day; // Max doses per day
// Calculate dynamic default time: max time + 1 hour, capped at 23:59
let defaultTime = '12:00';
if (!newDose?.time && day.doses.length > 0) {
// Find the latest time in the day
const times = day.doses.map(d => d.time || '00:00');
const maxTime = times.reduce((max, time) => time > max ? time : max, '00:00');
// Parse and add 1 hour
const [hours, minutes] = maxTime.split(':').map(Number);
let newHours = hours + 1;
// Cap at 23:59
if (newHours > 23) {
newHours = 23;
defaultTime = '23:59';
} else {
defaultTime = `${newHours.toString().padStart(2, '0')}:00`;
}
}
const dose: DayDose = { const dose: DayDose = {
id: `dose-${Date.now()}-${Math.random()}`, id: `dose-${Date.now()}-${Math.random()}`,
time: newDose?.time || '12:00', time: newDose?.time || defaultTime,
ldx: newDose?.ldx || '0', ldx: newDose?.ldx || '0',
damph: newDose?.damph || '0', damph: newDose?.damph || '0',
isFed: newDose?.isFed || false,
}; };
return { ...day, doses: [...day.doses, dose] }; return { ...day, doses: [...day.doses, dose] };
@@ -238,11 +360,151 @@ export const useAppState = () => {
})); }));
}; };
const handleReset = () => { // Profile management functions
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.")) { const getActiveProfile = (): ScheduleProfile | undefined => {
window.localStorage.removeItem(LOCAL_STORAGE_KEY); return appState.profiles.find(p => p.id === appState.activeProfileId);
window.location.reload(); };
const createProfile = (name: string, cloneFromId?: string): string | null => {
if (appState.profiles.length >= MAX_PROFILES) {
console.warn(`Cannot create profile: Maximum of ${MAX_PROFILES} profiles reached`);
return null;
} }
const now = new Date().toISOString();
const newProfileId = `profile-${Date.now()}`;
let days: DayGroup[];
if (cloneFromId) {
const sourceProfile = appState.profiles.find(p => p.id === cloneFromId);
days = sourceProfile ? JSON.parse(JSON.stringify(sourceProfile.days)) : appState.days;
} else {
// Create with current days
days = JSON.parse(JSON.stringify(appState.days));
}
// Regenerate IDs for cloned days/doses
days = days.map(day => ({
...day,
id: `day-${Date.now()}-${Math.random()}`,
doses: day.doses.map(dose => ({
...dose,
id: `dose-${Date.now()}-${Math.random()}`
}))
}));
const newProfile: ScheduleProfile = {
id: newProfileId,
name,
days,
createdAt: now,
modifiedAt: now
};
setAppState(prev => ({
...prev,
profiles: [...prev.profiles, newProfile]
}));
return newProfileId;
};
const deleteProfile = (profileId: string): boolean => {
if (appState.profiles.length <= 1) {
console.warn('Cannot delete last profile');
return false;
}
const profileIndex = appState.profiles.findIndex(p => p.id === profileId);
if (profileIndex === -1) {
console.warn('Profile not found');
return false;
}
setAppState(prev => {
const newProfiles = prev.profiles.filter(p => p.id !== profileId);
// If we're deleting the active profile, switch to first remaining profile
let newActiveProfileId = prev.activeProfileId;
if (profileId === prev.activeProfileId) {
newActiveProfileId = newProfiles[0].id;
}
return {
...prev,
profiles: newProfiles,
activeProfileId: newActiveProfileId,
days: newProfiles.find(p => p.id === newActiveProfileId)?.days || prev.days
};
});
return true;
};
const switchProfile = (profileId: string) => {
const profile = appState.profiles.find(p => p.id === profileId);
if (!profile) {
console.warn('Profile not found');
return;
}
setAppState(prev => ({
...prev,
activeProfileId: profileId,
days: profile.days
}));
};
const saveProfile = () => {
const now = new Date().toISOString();
setAppState(prev => ({
...prev,
profiles: prev.profiles.map(p =>
p.id === prev.activeProfileId
? { ...p, days: JSON.parse(JSON.stringify(prev.days)), modifiedAt: now }
: p
)
}));
};
const saveProfileAs = (newName: string): string | null => {
const newProfileId = createProfile(newName, undefined);
if (newProfileId) {
// Save current days to the new profile and switch to it
const now = new Date().toISOString();
setAppState(prev => ({
...prev,
profiles: prev.profiles.map(p =>
p.id === newProfileId
? { ...p, days: JSON.parse(JSON.stringify(prev.days)), modifiedAt: now }
: p
),
activeProfileId: newProfileId
}));
}
return newProfileId;
};
const updateProfileName = (profileId: string, newName: string) => {
setAppState(prev => ({
...prev,
profiles: prev.profiles.map(p =>
p.id === profileId
? { ...p, name: newName, modifiedAt: new Date().toISOString() }
: p
)
}));
};
const hasUnsavedChanges = (): boolean => {
const activeProfile = getActiveProfile();
if (!activeProfile) return false;
return JSON.stringify(activeProfile.days) !== JSON.stringify(appState.days);
}; };
return { return {
@@ -259,6 +521,14 @@ export const useAppState = () => {
updateDoseInDay, updateDoseInDay,
updateDoseFieldInDay, updateDoseFieldInDay,
sortDosesInDay, sortDosesInDay,
handleReset // Profile management
getActiveProfile,
createProfile,
deleteProfile,
switchProfile,
saveProfile,
saveProfileAs,
updateProfileName,
hasUnsavedChanges
}; };
}; };

33
src/hooks/useDebounce.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* useDebounce Hook
*
* Debounces a value to prevent excessive updates.
* Useful for performance optimization with frequently changing values.
*
* @author Andreas Weyer
* @license MIT
*/
import { useEffect, useState } from 'react';
/**
* Debounces a value by delaying its update
* @param value - The value to debounce
* @param delay - Delay in milliseconds (default: 150ms)
* @returns The debounced value
*/
export function useDebounce<T>(value: T, delay: number = 150): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,71 @@
/**
* useElementSize Hook
*
* Tracks element dimensions using ResizeObserver with debouncing.
* More efficient than window resize events for container-specific sizing.
*
* @author Andreas Weyer
* @license MIT
*/
import { useEffect, useState, RefObject } from 'react';
import { useDebounce } from './useDebounce';
interface ElementSize {
width: number;
height: number;
}
/**
* Hook to track element size with debouncing
* @param ref - React ref to the element to observe
* @param debounceDelay - Delay in milliseconds for debouncing (default: 150ms)
* @returns Current element dimensions (debounced)
*/
export function useElementSize<T extends HTMLElement>(
ref: RefObject<T | null>,
debounceDelay: number = 150
): ElementSize {
const [size, setSize] = useState<ElementSize>({
width: 1000,
height: 600,
});
// Debounce the size to prevent excessive re-renders
const debouncedSize = useDebounce(size, debounceDelay);
useEffect(() => {
const element = ref.current;
if (!element) return;
// Set initial size (guard against 0 dimensions)
const initialWidth = element.clientWidth;
const initialHeight = element.clientHeight;
if (initialWidth > 0 && initialHeight > 0) {
setSize({
width: initialWidth,
height: initialHeight,
});
}
// Use ResizeObserver for efficient element size tracking
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
// Guard against invalid dimensions
if (width > 0 && height > 0) {
setSize({ width, height });
}
}
});
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
};
}, [ref]);
return debouncedSize;
}

View File

@@ -72,7 +72,8 @@ export const useSimulation = (appState: AppState) => {
doses: templateDay.doses.map(d => ({ doses: templateDay.doses.map(d => ({
id: `${d.id}-template-${i}`, id: `${d.id}-template-${i}`,
time: d.time, time: d.time,
ldx: d.ldx ldx: d.ldx,
isFed: d.isFed // Preserve food-timing flag for proper absorption delay modeling
})) }))
})); }));

View File

@@ -0,0 +1,46 @@
/**
* useWindowSize Hook
*
* Tracks window dimensions with debouncing to prevent excessive re-renders
* during window resize operations.
*
* @author Andreas Weyer
* @license MIT
*/
import { useEffect, useState } from 'react';
import { useDebounce } from './useDebounce';
interface WindowSize {
width: number;
height: number;
}
/**
* Hook to track window size with debouncing
* @param debounceDelay - Delay in milliseconds for debouncing (default: 150ms)
* @returns Current window dimensions (debounced)
*/
export function useWindowSize(debounceDelay: number = 150): WindowSize {
const [windowSize, setWindowSize] = useState<WindowSize>({
width: typeof window !== 'undefined' ? window.innerWidth : 1000,
height: typeof window !== 'undefined' ? window.innerHeight : 800,
});
// Debounce the window size to prevent excessive re-renders
const debouncedWindowSize = useDebounce(windowSize, debounceDelay);
useEffect(() => {
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return debouncedWindowSize;
}

View File

@@ -10,7 +10,7 @@ export const de = {
lisdexamfetamine: "Lisdexamfetamin", lisdexamfetamine: "Lisdexamfetamin",
lisdexamfetamineShort: "LDX", lisdexamfetamineShort: "LDX",
both: "Beide", both: "Beide",
regularPlanOverlayShort: "Reg.", regularPlanOverlayShort: "Basis",
// Language selector // Language selector
languageSelectorLabel: "Sprache", languageSelectorLabel: "Sprache",
@@ -23,7 +23,7 @@ export const de = {
themeSelectorSystem: "💻 System", themeSelectorSystem: "💻 System",
// Dose Schedule // Dose Schedule
myPlan: "Mein Plan", myPlan: "Mein Zeitplan",
morning: "Morgens", morning: "Morgens",
midday: "Mittags", midday: "Mittags",
afternoon: "Nachmittags", afternoon: "Nachmittags",
@@ -32,8 +32,33 @@ export const de = {
doseWithFood: "Mit Nahrung eingenommen (verzögert Absorption ~1h)", doseWithFood: "Mit Nahrung eingenommen (verzögert Absorption ~1h)",
doseFasted: "Nüchtern eingenommen (normale Absorption)", doseFasted: "Nüchtern eingenommen (normale Absorption)",
// Schedule Management
savedPlans: "Gespeicherte Zeitpläne",
profileSaveAsNewProfile: "Als neuen Zeitplan speichern",
profileSave: "Änderungen im aktuellen Zeitplan speichern",
profileSaveAs: "Neuen Zeitplan mit aktueller Konfiguration erstellen",
profileRename: "Diesen Zeitplan umbenennen",
profileRenameHelp: "Geben Sie einen neuen Namen für den Zeitplan ein und drücken Sie Enter oder klicken Sie auf Speichern",
profileRenamePlaceholder: "Neuer Name für den Zeitplan...",
profileDelete: "Diesen Zeitplan löschen",
profileDeleteDisabled: "Der letzte Zeitplan kann nicht gelöscht werden",
profileDeleteConfirm: "Möchten Sie den Zeitplan '{name}' wirklich löschen?",
profileSaveAsPlaceholder: "Name für den neuen Zeitplan...",
profileSaveAsHelp: "Geben Sie einen Namen für den neuen Zeitplan ein und drücken Sie Enter oder klicken Sie auf Speichern",
profileNameAlreadyExists: "Ein Zeitplan mit diesem Namen existiert bereits",
profileSwitchUnsavedConfirm: "Sie haben ungespeicherte Änderungen. Beim Wechseln des Zeitplans gehen diese verloren. Fortfahren?",
profiles: "Zeitpläne",
cancel: "Abbrechen",
// Export/Import schedules
exportAllProfiles: "Alle Zeitpläne exportieren",
exportAllProfilesTooltip: "__Wenn aktiviert:__ Exportiert alle gespeicherten Zeitpläne.\\n\\n__Wenn deaktiviert:__ Exportiert nur den aktuell aktiven Zeitplan. Wenn der aktive Zeitplan ungespeicherte Änderungen hat, werden diese im Export enthalten sein.",
mergeProfiles: "Mit vorhandenen Zeitplänen zusammenführen",
mergeProfilesTooltip: "Wenn aktiviert, werden importierte Zeitpläne zu Ihren vorhandenen hinzugefügt. Wenn deaktiviert, werden alle aktuellen Zeitpläne ersetzt.\\n\\n__Standard:__ **deaktiviert** (alle ersetzen)",
deleteRestoreExamples: "Beispielzeitpläne nach Löschung wiederherstellen",
// Deviations // Deviations
deviationsFromPlan: "Abweichungen vom Plan", deviationsFromPlan: "Abweichungen vom Zeitplan",
addDeviation: "Abweichung hinzufügen", addDeviation: "Abweichung hinzufügen",
day: "Tag", day: "Tag",
additional: "Zusätzlich", additional: "Zusätzlich",
@@ -53,13 +78,13 @@ export const de = {
axisLabelHours: "Stunden (h)", axisLabelHours: "Stunden (h)",
axisLabelTimeOfDay: "Tageszeit (h)", axisLabelTimeOfDay: "Tageszeit (h)",
tickNoon: "Mittag", tickNoon: "Mittag",
refLineRegularPlan: "Regulär", refLineRegularPlan: "Basis",
refLineNoDeviation: "Regulär", refLineNoDeviation: "Basis",
refLineRecovering: "Erholung", refLineRecovering: "Erholung",
refLineIrregularIntake: "Irregulär", refLineIrregularIntake: "Irregulär",
refLineDayX: "T{{x}}", refLineDayX: "T{{x}}",
refLineRegularPlanShort: "(Reg.)", refLineRegularPlanShort: "(Basis)",
refLineNoDeviationShort: "(Reg.)", refLineNoDeviationShort: "(Basis)",
refLineRecoveringShort: "(Erh.)", refLineRecoveringShort: "(Erh.)",
refLineIrregularIntakeShort: "(Irr.)", refLineIrregularIntakeShort: "(Irr.)",
refLineDayShort: "T{{x}}", refLineDayShort: "T{{x}}",
@@ -67,7 +92,7 @@ export const de = {
refLineMax: "Max", refLineMax: "Max",
pinChart: "Diagramm oben fixieren", pinChart: "Diagramm oben fixieren",
unpinChart: "Diagramm freigeben", unpinChart: "Diagramm freigeben",
stickyChartTooltip: "Diagramm beim Scrollen durch die Einstellungen sichtbar halten, um Änderungen in Echtzeit zu sehen. Standard: aus.", stickyChartTooltip: "Diagramm beim Scrollen durch die Einstellungen sichtbar halten, um Änderungen in Echtzeit zu sehen.\\n\\n__Standard:__ **aus**",
chartViewDamphTooltip: "Nur den aktiven Metaboliten (d-Amphetamin) im Konzentrationsverlauf anzeigen", chartViewDamphTooltip: "Nur den aktiven Metaboliten (d-Amphetamin) im Konzentrationsverlauf anzeigen",
chartViewLdxTooltip: "Nur das Prodrug (Lisdexamfetamin) im Konzentrationsverlauf anzeigen", chartViewLdxTooltip: "Nur das Prodrug (Lisdexamfetamin) im Konzentrationsverlauf anzeigen",
chartViewBothTooltip: "Sowohl d-Amphetamin als auch Lisdexamfetamin gemeinsam anzeigen", chartViewBothTooltip: "Sowohl d-Amphetamin als auch Lisdexamfetamin gemeinsam anzeigen",
@@ -79,11 +104,13 @@ export const de = {
advancedSettings: "Erweiterte Einstellungen", advancedSettings: "Erweiterte Einstellungen",
advancedSettingsWarning: "⚠️ Diese Parameter beeinflussen die Simulationsgenauigkeit und können von Bevölkerungsdurchschnitten abweichen. Nur anpassen, wenn spezifische klinische Daten oder Forschungsreferenzen vorliegen.", advancedSettingsWarning: "⚠️ Diese Parameter beeinflussen die Simulationsgenauigkeit und können von Bevölkerungsdurchschnitten abweichen. Nur anpassen, wenn spezifische klinische Daten oder Forschungsreferenzen vorliegen.",
standardVolumeOfDistribution: "Verteilungsvolumen (Vd)", standardVolumeOfDistribution: "Verteilungsvolumen (Vd)",
standardVdTooltip: "Definiert wie sich der Wirkstoff im Körper verteilt. Erwachsene: 377L (Roberts 2015), Kinder: ~150-200L. Beeinflusst alle Konzentrationsberechnungen. Nur für pädiatrische oder spezialisierte Simulationen ändern. Standard: {{standardVdValue}}L ({{standardVdPreset}}).", standardVdTooltip: "Definiert wie sich der Wirkstoff im Körper verteilt.\\n\\n__Voreinstellungen:__\\n• Erwachsene: 377L (Roberts 2015)\\n• Kinder: ~150-200L\\n• Gewichtsbasiert: ~5,4 L/kg (für Erwachsene >18 Jahre basierend auf [Populations-Pharmakokinetik](https://pmc.ncbi.nlm.nih.gov/articles/PMC5572767/))\\n\\nBeeinflusst alle Konzentrationsberechnungen. Nur für pädiatrische oder spezialisierte Simulationen ändern.\\n\\n__Standard:__ **{{standardVdValue}}L** ({{standardVdPreset}})",
standardVdPresetAdult: "Erwachsene (377L)", standardVdPresetAdult: "Erwachsene (377L)",
standardVdPresetChild: "Kinder (175L)", standardVdPresetChild: "Kinder (175L)",
standardVdPresetCustom: "Benutzerdefiniert", standardVdPresetCustom: "Benutzerdefiniert",
standardVdPresetWeightBased: "Gewichtsbasiert (~5,4 L/kg)",
customVdValue: "Benutzerdefiniertes Vd (L)", customVdValue: "Benutzerdefiniertes Vd (L)",
weightBasedVdInfo: "Gewichtsbasiertes Vd passt Plasmakonzentrationen basierend auf Körpergewicht an (~5,4 L/kg). Leichtere Personen → höhere Spitzen, schwerere → niedrigere Spitzen.\\n\\nDiese Option ist für Erwachsene (>18 Jahre) basierend auf der Populations-PK-Studie vorgesehen.\\n\\nFür pädiatrische Patienten verwenden Sie die Voreinstellung 'Kinder'.",
xAxisTimeFormat: "Zeitformat", xAxisTimeFormat: "Zeitformat",
xAxisFormatContinuous: "Fortlaufend", xAxisFormatContinuous: "Fortlaufend",
xAxisFormatContinuousDesc: "Endlose Sequenz (0h, 6h, 12h...)", xAxisFormatContinuousDesc: "Endlose Sequenz (0h, 6h, 12h...)",
@@ -91,72 +118,76 @@ 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 anzeigen", showTemplateDayInChart: "Basis-Zeitplan zum Vergleich anzeigen",
showTemplateDayTooltip: "Medikationsplan als Referenz-Overlay jederzeit anzeigen (Standard: aktiviert).", showTemplateDayTooltip: "Führt die Simulation des Basis-Zeitplans auch dann fort, auch wenn für Tag 2+ abweichende Zeitpläne definiert sind. Die entsprechenden Plasmakonzentrationen werden, nur im Falle einer Abweichung vom Basis-Zeitplan, als zusätzliche gestrichelte Linien dargestellt.\\n\\n__Standard:__ **aktiviert**",
simulationSettings: "Simulations-Einstellungen", simulationSettings: "Simulations-Einstellungen",
showDayReferenceLines: "Tagestrenner anzeigen", showDayReferenceLines: "Tagestrenner anzeigen",
showDayReferenceLinesTooltip: "Vertikale Linien und Statusanzeigen zwischen Tagen anzeigen (Standard: aktiviert).", showDayReferenceLinesTooltip: "Vertikale Linien und Statusanzeigen zwischen Tagen anzeigen.\\n\\n__Standard:__ **aktiviert**", showIntakeTimeLines: "Einnahmezeitmarkierungen anzeigen",
showTherapeuticRangeLines: "Therapeutischen Bereich anzeigen", showIntakeTimeLinesTooltip: "Vertikale gestrichelte Linien an Einnahmezeiten mit Dosis-Index-Labels anzeigen.\\n\\n__Standard:__ **deaktiviert**", showTherapeuticRangeLines: "Therapeutischen Bereich anzeigen ",
showTherapeuticRangeLinesTooltip: "Horizontale Referenzlinien für therapeutisches Min/Max anzeigen (Standard: aktiviert).", showTherapeuticRangeLinesTooltip: "Horizontale Referenzlinien für therapeutisches Min/Max anzeigen.\\n\\n__Standard:__ **aktiviert**",
simulationDuration: "Simulationsdauer", simulationDuration: "Simulationsdauer",
simulationDurationTooltip: "Anzahl der zu simulierenden Tage. Längere Zeiträume zeigen Steady-State. Standard: {{simulationDays}} Tage.", simulationDurationTooltip: "Anzahl der zu simulierenden Tage. Längere Zeiträume zeigen Steady-State.\\n\\n__Standard:__ **{{simulationDays}} Tage**",
displayedDays: "Sichtbare Tage (im Fokus)", displayedDays: "Sichtbare Tage (im Fokus)",
displayedDaysTooltip: "Wie viele Tage auf einmal angezeigt werden. Kleinere Werte zoomen in Details. Standard: {{displayedDays}} Tag(e).", displayedDaysTooltip: "Wie viele Tage auf einmal angezeigt werden. Kleinere Werte zoomen in Details.\\n\\n__Standard:__ **{{displayedDays}} Tag(e)**",
yAxisRange: "Y-Achsen-Bereich (Konzentrations-Zoom)", yAxisRange: "Y-Achsen-Bereich (Konzentrations-Zoom)",
yAxisRangeTooltip: "Vertikale Achse manuell festlegen (Konzentrationsskala). Leer lassen für automatische Anpassung. Standard: auto.", yAxisRangeTooltip: "Vertikale Achse manuell festlegen (Konzentrationsskala). Leer lassen für automatische Anpassung.\\n\\n__Standard:__ **auto**",
yAxisRangeAutoButton: "A", yAxisRangeAutoButton: "A",
yAxisRangeAutoButtonTitle: "Bereich automatisch anhand des Datenbereichs bestimmen", yAxisRangeAutoButtonTitle: "Bereich automatisch anhand des Datenbereichs bestimmen",
auto: "Auto", auto: "Auto",
therapeuticRange: "Therapeutischer Bereich", therapeuticRange: "Therapeutischer Bereich (d-Amphetamin)",
therapeuticRangeTooltip: "Personalisierte Konzentrationsziele basierend auf DEINER individuellen Reaktion. Setze diese nachdem du beobachtet hast, welche Werte Symptomkontrolle vs. Nebenwirkungen bieten. Referenzbereiche (stark variabel): Erwachsene ~10-80 ng/mL, Kinder ~20-120 ng/mL (aufgrund geringeren Körpergewichts/Vd). Leer lassen wenn unsicher. Konsultiere deinen Arzt.", therapeuticRangeTooltip: "Personalisierte Konzentrationsziele basierend auf DEINER individuellen Reaktion. Setze diese nachdem du beobachtet hast, welche Werte Symptomkontrolle vs. Nebenwirkungen bieten.\\n\\n**Referenzbereiche** (stark variabel):\\n• __Erwachsene:__ **~10-80 ng/mL**\\n• __Kinder:__ **~20-120 ng/mL** (aufgrund geringeren Körpergewichts/Vd)\\n\\nLeer lassen wenn unsicher.\\n\\n***Konsultiere deinen Arzt.***",
dAmphetamineParameters: "d-Amphetamin Parameter", dAmphetamineParameters: "d-Amphetamin Parameter",
halfLife: "Eliminations-Halbwertszeit", halfLife: "Eliminations-Halbwertszeit",
halfLifeTooltip: "Zeit bis der Körper die Hälfte des d-Amphetamins aus dem Blut ausscheidet. Beeinflusst durch Urin-pH: sauer (<6) → 7-9h, neutral (6-7,5) → 10-12h, alkalisch (>7,5) → 13-15h. Siehe [therapeutische Referenzbereiche](https://www.thieme-connect.com/products/ejournals/pdf/10.1055/a-2689-4911.pdf). Standard: {{damphHalfLife}}h.", halfLifeTooltip: "Zeit bis der Körper die Hälfte des d-Amphetamins aus dem Blut ausscheidet.\\n\\n__Beeinflusst durch Urin-pH:__\\n• __Sauer (<6):__ **7-9h**\\n• __Neutral (6-7,5)__**10-12h**\\n• __Alkalisch (>7,5)__**13-15h**\\n\\nSiehe [therapeutische Referenzbereiche](https://www.thieme-connect.com/products/ejournals/pdf/10.1055/a-2689-4911.pdf).\\n\\n__Standard:__ **{{damphHalfLife}}h**",
lisdexamfetamineParameters: "Lisdexamfetamin (LDX) Parameter", lisdexamfetamineParameters: "Lisdexamfetamin (LDX) Parameter",
conversionHalfLife: "LDX→d-Amph Umwandlungs-Halbwertszeit", conversionHalfLife: "LDX→d-Amph Umwandlungs-Halbwertszeit",
conversionHalfLifeTooltip: "Zeit bis rote Blutkörperchen die Hälfte des inaktiven LDX-Prodrugs in aktives d-Amphetamin umwandeln. Typisch: 0,7-1,2h. Standard: {{ldxHalfLife}}h.", conversionHalfLifeTooltip: "Zeit bis rote Blutkörperchen die Hälfte des inaktiven LDX-Prodrugs in aktives d-Amphetamin umwandeln.\\n\\nTypischer Bereich: **0,7-1,2h**.\\n\\n__Standard:__ **{{ldxHalfLife}}h**",
absorptionHalfLife: "Absorptions-Halbwertszeit", absorptionHalfLife: "Absorptions-Halbwertszeit",
absorptionHalfLifeTooltip: "Zeit bis der Darm die Hälfte des LDX vom Magen ins Blut aufnimmt. Durch Nahrung verzögert (~1h Verschiebung). Typisch: 0,7-1,2h. Standard: {{ldxAbsorptionHalfLife}}h.", absorptionHalfLifeTooltip: "Zeit bis der Darm die Hälfte des LDX vom Magen ins Blut aufnimmt.\\n\\nDurch Nahrung verzögert (**~1h Verschiebung**).\\n\\nTypischer Bereich: **0,7-1,2h**.\\n\\n__Standard:__ **{{ldxAbsorptionHalfLife}}h**",
faster: "(schneller >)", faster: "(schneller >)",
// Advanced Settings // Advanced Settings
weightBasedVdScaling: "Gewichtsbasiertes Verteilungsvolumen", weightBasedVdScaling: "Gewichtsbasiertes Verteilungsvolumen",
weightBasedVdTooltip: "Passt Plasmakonzentrationen basierend auf Körpergewicht an (proportional zu ~5,4 L/kg). Leichtere → höhere Spitzen, schwerere → niedrigere. Bei Deaktivierung: 70 kg Erwachsener.", weightBasedVdTooltip: "Passt Plasmakonzentrationen basierend auf Körpergewicht an (proportional zu **~5,4 L/kg**).\\n\\n__Effekte:__\\n• Leichtere Personen → ***höhere*** Konzentrationsspitzen\\n• __Schwerere Personen__ → ***niedrigere*** Konzentrationsspitzen\\n\\n__Bei Deaktivierung:__ **70 kg Erwachsene Person**",
bodyWeight: "Körpergewicht", bodyWeight: "Körpergewicht",
bodyWeightTooltip: "Dein Körpergewicht für Konzentrationsanpassung. Verwendet zur Berechnung des Verteilungsvolumens (Vd = Gewicht × 5,4). Siehe [Populations-Pharmakokinetik](https://pmc.ncbi.nlm.nih.gov/articles/PMC5572767/). Standard: {{bodyWeight}} kg.", bodyWeightTooltip: "Dein Körpergewicht für Konzentrationsanpassung. Verwendet zur Berechnung des Verteilungsvolumens:\\n\\n**Vd = Gewicht × 5,4**\\n\\nSiehe [Populations-Pharmakokinetik](https://pmc.ncbi.nlm.nih.gov/articles/PMC5572767/).\\n\\n__Standard:__ **{{bodyWeight}} kg**",
bodyWeightUnit: "kg", bodyWeightUnit: "kg",
foodEffectEnabled: "Mit Mahlzeit eingenommen", foodEffectEnabled: "Mit Mahlzeit eingenommen",
foodEffectDelay: "Nahrungseffekt-Verzögerung", foodEffectDelay: "Nahrungseffekt-Verzögerung",
foodEffectTooltip: "Fettreiche Mahlzeiten verzögern die Absorption ohne die Gesamtaufnahme zu ändern. Verlangsamt Wirkungseintritt (~1h Verzögerung). Deaktiviert nimmt nüchternen Zustand an.", foodEffectTooltip: "Fettreiche Mahlzeiten verzögern die Absorption **ohne die Gesamtaufnahme zu ändern**.\\n\\nVerlangsamt Wirkungseintritt um **~1 Stunde**.\\n\\nDeaktiviert nimmt nüchternen Zustand an.",
tmaxDelay: "Absorptions-Verzögerung", tmaxDelay: "Absorptions-Verzögerung",
tmaxDelayTooltip: "Zeitverzögerung bei Einnahme mit fettreicher Mahlzeit. Wird durch Einzel-Dosis Nahrungsschalter (🍴 Symbol) im Zeitplan angewendet. Forschung zeigt ~1h Verzögerung ohne Spitzenreduktion. Siehe [Studie](https://pmc.ncbi.nlm.nih.gov/articles/PMC4823324/). Standard: {{tmaxDelay}}h.", tmaxDelayTooltip: "Zeitverzögerung bei Einnahme mit **fettreicher Mahlzeit**. Wird durch Einzel-Dosis Nahrungsschalter (🍴 Symbol) im Zeitplan angewendet.\\n\\nForschung zeigt ~1h Verzögerung ohne Spitzenreduktion. Siehe [Studie](https://pmc.ncbi.nlm.nih.gov/articles/PMC4823324/).\\n\\n__Hinweis:__ Die in dieser Studie verwendete fettreiche Mahlzeit bestand aus 1 englischem Muffin mit Butter, 1 Spiegelei, 1 Scheibe amerikanischem Käse, 1 Scheibe kanadischem Speck, 57 g Bratkartoffeln und 240 ml Vollmilch.\\n\\n__Standard:__ **{{tmaxDelay}}h**",
tmaxDelayUnit: "h", tmaxDelayUnit: "h",
urinePHTendency: "Urin-pH-Effekte", urinePHTendency: "Urin-pH-Effekte",
urinePHTooltip: "Urin-pH beeinflusst Nierenrückresorption von Amphetamin. Ermöglicht pH-abhängige Halbwertszeit-Variation (7-15h Bereich). Bei Deaktivierung: neutraler pH (~11h HWZ).", urinePHTooltip: "Urin-pH beeinflusst Nierenrückresorption von Amphetamin.\\n\\n__Effekte auf die Elimination:__\\n• __Sauer__ (<6): ***Erhöhte*** Elimination (***schnellere*** Ausscheidung), **t½ ~7-9h**\\n• __Normal__ (6-7,5): ***Basis***-Elimination (**t½ ~11h**)\\n• __Alkalisch__ (>7,5) → ***Reduzierte*** Elimination (***langsamere*** Ausscheidung), **t½ ~13-15h**\\n\\nTypischer Bereich: 5,5-8,0.\\n\\n__Standard:__ **Normaler pH** (6-7,5)",
urinePHMode: "pH-Effekt",
urinePHModeNormal: "Normal (pH 6-7,5, t½ 11h)",
urinePHModeAcidic: "Sauer (pH <6, schnellere Elimination)",
urinePHModeAlkaline: "Alkalisch (pH >7,5, langsamere Elimination)",
urinePHValue: "pH-Wert", urinePHValue: "pH-Wert",
urinePHValueTooltip: "Dein typischer Urin-pH (sauer=schnellere Ausscheidung, alkalisch=langsamer). Standard: {{phTendency}}. Bereich: 5,5-8,0.", urinePHValueTooltip: "Dein typischer Urin-pH (sauer=schnellere Ausscheidung, alkalisch=langsamer).\\n\\nBereich: **5,5-8,0**.\\n\\n__Standard:__ **{{phTendency}}**",
phValue: "pH-Wert", phValue: "pH-Wert",
phUnit: "(5,5-8,0)", phUnit: "(5,5-8,0)",
oralBioavailability: "Orale Bioverfügbarkeit", oralBioavailability: "Orale Bioverfügbarkeit",
oralBioavailabilityTooltip: "Anteil der LDX-Dosis, der ins Blut gelangt. Siehe [Bioverfügbarkeitsstudie](https://www.frontiersin.org/journals/pharmacology/articles/10.3389/fphar.2022.881198/full) (FDA-Label: 96,4%). Selten Anpassung nötig, außer bei dokumentierten Absorptionsproblemen. Standard: {{fOral}} ({{fOralPercent}}%).", oralBioavailabilityTooltip: "Anteil der LDX-Dosis, der ins Blut gelangt.\\n\\nSiehe [Bioverfügbarkeitsstudie](https://www.frontiersin.org/journals/pharmacology/articles/10.3389/fphar.2022.881198/full) — **FDA-Label: 96,4%**.\\n\\nSelten Anpassung nötig, außer bei dokumentierten Absorptionsproblemen.\\n\\n__Standard:__ **{{fOral}} ({{fOralPercent}}%)**",
steadyStateDays: "Medikationshistorie", steadyStateDays: "Medikationshistorie",
steadyStateDaysTooltip: "Anzahl vorheriger Tage stabiler Medikamentendosis zur Simulation der Akkumulation/Steady-State. 0 setzen für \"erster Tag ohne Vorgeschichte.\" Standard: {{steadyStateDays}} Tage. Max: 7.", steadyStateDaysTooltip: "Anzahl vorheriger Tage stabiler Medikamentendosis zur Simulation der Akkumulation/Steady-State.\\n\\nWird diese Option ausgeschaltet, beginnt die Simulation an Tag eins ohne vorherige Medikationshistorie. Dasselbe gilt für den Wert **0**.\\n\\nMax: **7 Tage**.\\n\\n__Standard:__ **{{steadyStateDays}} Tage**",
// Age-specific pharmacokinetics // Age-specific pharmacokinetics
ageGroup: "Altersgruppe", ageGroup: "Altersgruppe",
ageGroupTooltip: "Pädiatrische Personen (6-12 J.) zeigen schnellere d-Amphetamin-Elimination (t½ ~9h) verglichen mit Erwachsenen (~11h) aufgrund höherer gewichtsnormalisierter Stoffwechselrate. Siehe [Forschungsdokument](https://git.11001001.org/cbaoth/med-plan-assistant/src/branch/main/docs/2026-01-17_AI-Reseach_SimulatingLDXandD-AmphetaminePlasmaLevels.md#52-pediatric-vs-adult-modeling) Abschnitt 5.2. 'Benutzerdefiniert' wählen, um manuell konfigurierte Halbwertszeit zu verwenden. Standard: Erwachsener.", ageGroupTooltip: "Pädiatrische Personen (6-12 J.) zeigen **schnellere d-Amphetamin-Elimination** (t½ ~9h) verglichen mit Erwachsenen (~11h) aufgrund höherer gewichtsnormalisierter Stoffwechselrate.\\n\\nSiehe [Forschungsdokument](https://git.11001001.org/cbaoth/med-plan-assistant/src/branch/main/docs/2026-01-17_AI-Reseach_SimulatingLDXandD-AmphetaminePlasmaLevels.md#52-pediatric-vs-adult-modeling) Abschnitt 5.2.\\n\\n'Benutzerdefiniert' wählen, um manuell konfigurierte Halbwertszeit zu verwenden.\\n\\n__Standard:__ **Erwachsener**",
ageGroupAdult: "Erwachsener (t½ 11h)", ageGroupAdult: "Erwachsener (t½ 11h)",
ageGroupChild: "Kind 6-12 J. (t½ 9h)", ageGroupChild: "Kind 6-12 J. (t½ 9h)",
ageGroupCustom: "Benutzerdefiniert (manuelle t½)", ageGroupCustom: "Benutzerdefiniert (manuelle t½)",
// Renal function effects // Renal function effects
renalFunction: "Niereninsuffizienz", renalFunction: "Niereninsuffizienz",
renalFunctionTooltip: "Schwere Niereninsuffizienz verlängert d-Amphetamin-Halbwertszeit um ~50% (von 11h auf 16,5h). FDA-Label empfiehlt Dosierungsobergrenzen: 50mg bei schwerer Insuffizienz, 30mg bei Nierenversagen (ESRD). Siehe [FDA-Label Abschnitt 8.6](https://www.accessdata.fda.gov/drugsatfda_docs/label/2017/021977s049lbl.pdf) und [Forschungsdokument](https://git.11001001.org/cbaoth/med-plan-assistant/src/branch/main/docs/2026-01-17_AI-Reseach_SimulatingLDXandD-AmphetaminePlasmaLevels.md#82-renal-function) Abschnitt 8.2. Standard: deaktiviert.", renalFunctionTooltip: "Schwere Niereninsuffizienz verlängert d-Amphetamin-Halbwertszeit um **~50%** (von 11h auf 16,5h).\\n\\n__FDA-Label Dosierungsobergrenzen:__\\n• __Schwere Insuffizienz:__ **50mg**\\n• __Nierenversagen (ESRD):__ **30mg**\\n\\nSiehe [FDA-Label Abschnitt 8.6](https://www.accessdata.fda.gov/drugsatfda_docs/label/2017/021977s049lbl.pdf) und [Forschungsdokument](https://git.11001001.org/cbaoth/med-plan-assistant/src/branch/main/docs/2026-01-17_AI-Reseach_SimulatingLDXandD-AmphetaminePlasmaLevels.md#82-renal-function) Abschnitt 8.2.\\n\\n__Standard:__ **deaktiviert**",
renalFunctionSeverity: "Schweregrad der Insuffizienz", renalFunctionSeverity: "Schweregrad der Insuffizienz",
renalFunctionNormal: "Normal (keine Anpassung)", renalFunctionNormal: "Normal (keine Anpassung)",
renalFunctionMild: "Leicht (keine Anpassung)", renalFunctionMild: "Leicht (keine Anpassung)",
@@ -165,7 +196,7 @@ export const de = {
resetAllSettings: "Alle Einstellungen zurücksetzen", resetAllSettings: "Alle Einstellungen zurücksetzen",
resetDiagramSettings: "Diagramm-Einstellungen zurücksetzen", resetDiagramSettings: "Diagramm-Einstellungen zurücksetzen",
resetPharmacokineticSettings: "Pharmakokinetik-Einstellungen zurücksetzen", resetPharmacokineticSettings: "Pharmakokinetik-Einstellungen zurücksetzen",
resetPlan: "Plan zurücksetzen", resetPlan: "Zeitplan zurücksetzen",
// Disclaimer Modal // Disclaimer Modal
disclaimerModalTitle: "Wichtiger medizinischer Haftungsausschluss", disclaimerModalTitle: "Wichtiger medizinischer Haftungsausschluss",
@@ -206,6 +237,8 @@ export const de = {
exportOptionSimulation: "Simulations-Einstellungen (Dauer, Bereich, Diagrammansicht)", exportOptionSimulation: "Simulations-Einstellungen (Dauer, Bereich, Diagrammansicht)",
exportOptionPharmaco: "Pharmakokinetik-Einstellungen (Halbwertszeiten, therapeutischer Bereich)", exportOptionPharmaco: "Pharmakokinetik-Einstellungen (Halbwertszeiten, therapeutischer Bereich)",
exportOptionAdvanced: "Erweiterte Einstellungen (Gewicht, Nahrung, pH, Bioverfügbarkeit)", exportOptionAdvanced: "Erweiterte Einstellungen (Gewicht, Nahrung, pH, Bioverfügbarkeit)",
exportOptionOtherData: "Andere Daten (Design, eingeklappte Karten, Sprache, Haftungsausschluss)",
exportOptionOtherDataTooltip: "UI-Präferenzen wie Design, eingeklappte Kartenstatus, Spracheinstellung und Haftungsausschluss-Bestätigung. Normalerweise nicht nötig beim Teilen von Zeitplänen mit anderen.",
exportButton: "Backup-Datei herunterladen", exportButton: "Backup-Datei herunterladen",
importButton: "Datei zum Importieren wählen", importButton: "Datei zum Importieren wählen",
importApplyButton: "Import anwenden", importApplyButton: "Import anwenden",
@@ -250,12 +283,24 @@ export const de = {
closeDataManagement: "Schließen", closeDataManagement: "Schließen",
pasteContentTooLarge: "Inhalt zu groß (max. 5000 Zeichen)", pasteContentTooLarge: "Inhalt zu groß (max. 5000 Zeichen)",
// Delete Data
deleteSpecificData: "Spezifische Daten löschen",
deleteSpecificDataTooltip: "Ausgewählte Datenkategorien dauerhaft von Ihrem Gerät löschen. Dieser Vorgang kann nicht rückgängig gemacht werden.",
deleteSelectWhat: "Was möchtest du löschen:",
deleteDataWarning: "⚠️ Warnung: Das Löschen ist dauerhaft und kann nicht rückgängig gemacht werden. Gelöschte Daten werden auf Standardwerte zurückgesetzt.",
deleteDataButton: "Ausgewählte Daten löschen",
deleteNoOptionsSelected: "Bitte wähle mindestens eine Kategorie zum Löschen aus.",
deleteDataConfirmTitle: "Bist du sicher, dass du die folgenden Daten dauerhaft löschen möchtest?",
deleteDataConfirmWarning: "Diese Aktion kann nicht rückgängig gemacht werden. Gelöschte Daten werden auf Werkseinstellungen zurückgesetzt.",
deleteDataSuccess: "Ausgewählte Daten wurden erfolgreich gelöscht.",
// Footer disclaimer // Footer disclaimer
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 // Number input field
buttonClear: "Feld löschen", buttonClear: "Feld löschen",
buttonResetToDefault: "Auf Standard zurücksetzen",
// Field validation - Errors // Field validation - Errors
errorNumberRequired: "⛔ Bitte gib eine gültige Zahl ein.", errorNumberRequired: "⛔ Bitte gib eine gültige Zahl ein.",
@@ -265,6 +310,8 @@ export const de = {
errorConversionHalfLifeRequired: "⛔ Umwandlungs-Halbwertszeit ist erforderlich.", errorConversionHalfLifeRequired: "⛔ Umwandlungs-Halbwertszeit ist erforderlich.",
errorTherapeuticRangeMinRequired: "⛔ Minimaler therapeutischer Bereich ist erforderlich.", errorTherapeuticRangeMinRequired: "⛔ Minimaler therapeutischer Bereich ist erforderlich.",
errorTherapeuticRangeMaxRequired: "⛔ Maximaler therapeutischer Bereich ist erforderlich.", errorTherapeuticRangeMaxRequired: "⛔ Maximaler therapeutischer Bereich ist erforderlich.",
errorTherapeuticRangeInvalid: "⛔ Maximum muss größer als Minimum sein.",
errorYAxisRangeInvalid: "⚠️ Maximum muss größer als Minimum sein.",
errorEliminationHalfLifeRequired: "⛔ Eliminations-Halbwertszeit ist erforderlich.", errorEliminationHalfLifeRequired: "⛔ Eliminations-Halbwertszeit ist erforderlich.",
// Field validation - Warnings // Field validation - Warnings
@@ -274,15 +321,17 @@ export const de = {
warningConversionOutOfRange: "⚠️ Typischer Bereich: 0,7-1,2h. Aktueller Wert könnte außerhalb klinischer Normen liegen.", warningConversionOutOfRange: "⚠️ Typischer Bereich: 0,7-1,2h. Aktueller Wert könnte außerhalb klinischer Normen liegen.",
warningEliminationOutOfRange: "⚠️ Typischer Bereich: 9-12h (normaler pH). Erweiterter Bereich 7-15h (pH-Effekte). Aktueller Wert ist ungewöhnlich.", warningEliminationOutOfRange: "⚠️ Typischer Bereich: 9-12h (normaler pH). Erweiterter Bereich 7-15h (pH-Effekte). Aktueller Wert ist ungewöhnlich.",
warningDoseAbove70mg: "⚠️ FDA-zugelassenes Maximum: 70 mg. Höhere Dosen haben keine Sicherheitsdaten und erhöhen kardiovaskuläre Risiken.", warningDoseAbove70mg: "⚠️ FDA-zugelassenes Maximum: 70 mg. Höhere Dosen haben keine Sicherheitsdaten und erhöhen kardiovaskuläre Risiken.",
warningDailyTotalAbove70mg: "⚠️ **Tagesgesamtdosis überschreitet empfohlenes Maximum.**\\n\\n__FDA-zugelassenes Maximum:__ **70 mg/Tag**.\\nIhre Tagesgesamtdosis: **{{total}} mg**.\\nKonsultieren Sie Ihren Arzt, bevor Sie diese Dosis überschreiten.",
errorDailyTotalAbove200mg: "⛔ **Tagesgesamtdosis überschreitet sichere Grenzen erheblich!**\\n\\nIhre Tagesgesamtdosis **{{total}} mg** überschreitet 200 mg/Tag, was **deutlich über FDA-zugelassenen Grenzen** liegt. *Bitte konsultieren Sie Ihren Arzt.*",
// Day-based schedule // Day-based schedule
regularPlan: "Regulärer Plan", regularPlan: "Basis-Zeitplan",
deviatingPlan: "Abweichung vom Plan", deviatingPlan: "Abweichung vom Zeitplan",
alternativePlan: "Alternativer Plan", alternativePlan: "Alternativer Zeitplan",
regularPlanOverlay: "Regulär", regularPlanOverlay: "Basis",
dayNumber: "Tag {{number}}", dayNumber: "Tag {{number}}",
cloneDay: "Tag klonen", cloneDay: "Tag klonen",
addDay: "Tag hinzufügen", addDay: "Tag hinzufügen (alternativer Zeitplan)",
addDose: "Dosis hinzufügen", addDose: "Dosis hinzufügen",
removeDose: "Dosis entfernen", removeDose: "Dosis entfernen",
removeDay: "Tag entfernen", removeDay: "Tag entfernen",
@@ -290,22 +339,27 @@ export const de = {
expandDay: "Tag ausklappen", expandDay: "Tag ausklappen",
dose: "Dosis", dose: "Dosis",
doses: "Dosen", doses: "Dosen",
comparedToRegularPlan: "verglichen mit regulärem Plan", comparedToRegularPlan: "verglichen mit Basis-Zeitplan",
time: "Zeit", time: "Zeitpunkt der Einnahme",
ldx: "LDX", ldx: "LDX",
damph: "d-amph", damph: "d-amph",
// URL sharing // URL sharing
sharePlan: "Plan teilen", sharePlan: "Zeitplan teilen",
viewingSharedPlan: "Du siehst einen geteilten Plan", viewingSharedPlan: "Du siehst einen geteilten Zeitplan",
saveAsMyPlan: "Als meinen Plan speichern", saveAsMyPlan: "Als meinen Zeitplan speichern",
discardSharedPlan: "Verwerfen", discardSharedPlan: "Verwerfen",
planCopiedToClipboard: "Plan-Link in Zwischenablage kopiert!", planCopiedToClipboard: "Zeitplan-Link in Zwischenablage kopiert!",
// Time picker // Time picker
timePickerHour: "Stunde", timePickerHour: "Stunde",
timePickerMinute: "Minute", timePickerMinute: "Minute",
timePickerApply: "Übernehmen", timePickerApply: "Übernehmen",
timePickerCancel: "Abbrechen",
// Input field placeholders
min: "Min",
max: "Max",
// Sorting // Sorting
sortByTime: "Nach Zeit sortieren", sortByTime: "Nach Zeit sortieren",

View File

@@ -10,7 +10,7 @@ export const en = {
lisdexamfetamine: "Lisdexamfetamine", lisdexamfetamine: "Lisdexamfetamine",
lisdexamfetamineShort: "LDX", lisdexamfetamineShort: "LDX",
both: "Both", both: "Both",
regularPlanOverlayShort: "Reg.", regularPlanOverlayShort: "Base",
// Language selector // Language selector
languageSelectorLabel: "Language", languageSelectorLabel: "Language",
@@ -23,7 +23,7 @@ export const en = {
themeSelectorSystem: "💻 System", themeSelectorSystem: "💻 System",
// Dose Schedule // Dose Schedule
myPlan: "My Plan", myPlan: "My Schedule",
morning: "Morning", morning: "Morning",
midday: "Midday", midday: "Midday",
afternoon: "Afternoon", afternoon: "Afternoon",
@@ -32,12 +32,37 @@ export const en = {
doseWithFood: "Taken with food (delays absorption ~1h)", doseWithFood: "Taken with food (delays absorption ~1h)",
doseFasted: "Taken fasted (normal absorption)", doseFasted: "Taken fasted (normal absorption)",
// Schedule Management
savedPlans: "Saved Schedules",
profileSaveAsNewProfile: "Save as new schedule",
profileSave: "Save changes to current schedule",
profileSaveAs: "Create new schedule with current configuration",
profileRename: "Rename this schedule",
profileRenameHelp: "Enter a new name for the schedule and press Enter or click Save",
profileRenamePlaceholder: "New name for the schedule...",
profileDelete: "Delete this schedule",
profileDeleteDisabled: "Cannot delete the last schedule",
profileDeleteConfirm: "Are you sure you want to delete the schedule '{name}'?",
profileSaveAsPlaceholder: "Name for the new schedule...",
profileSaveAsHelp: "Enter a name for the new schedule and press Enter or click Save",
profileNameAlreadyExists: "A schedule with this name already exists",
profileSwitchUnsavedConfirm: "You have unsaved changes. Switching schedules will discard them. Continue?",
profiles: "schedules",
cancel: "Cancel",
// Export/Import schedules
exportAllProfiles: "Export all schedules",
exportAllProfilesTooltip: "__When enabled:__ Exports all saved schedules.\\n\\n__When disabled:__ Exports only the currently active schedule. If the active schedule has unsaved changes, those changes will be included in the export.",
mergeProfiles: "Merge with existing schedules",
mergeProfilesTooltip: "If enabled, imported schedules will be added to your existing ones. If disabled, all current schedules will be replaced.\\n\\n__Default:__ **disabled** (replace all)",
deleteRestoreExamples: "Restore example schedules after deletion",
// Deviations // Deviations
deviationsFromPlan: "Deviations from Plan", deviationsFromPlan: "Deviations from Schedule",
addDeviation: "Add Deviation", addDeviation: "Add Deviation",
day: "Day", day: "Day",
additional: "Additional", additional: "Additional",
additionalTooltip: "Mark this if it was an extra dose instead of a replacement for a planned one.", additionalTooltip: "Mark this if it was an extra dose instead of a replacement for a scheduled one.",
// Suggestions // Suggestions
whatIf: "What if?", whatIf: "What if?",
@@ -53,13 +78,13 @@ export const en = {
axisLabelHours: "Hours (h)", axisLabelHours: "Hours (h)",
axisLabelTimeOfDay: "Time of Day (h)", axisLabelTimeOfDay: "Time of Day (h)",
tickNoon: "Noon", tickNoon: "Noon",
refLineRegularPlan: "Regular", refLineRegularPlan: "Baseline",
refLineNoDeviation: "Regular", refLineNoDeviation: "Baseline",
refLineRecovering: "Recovering", refLineRecovering: "Recovering",
refLineIrregularIntake: "Irregular", refLineIrregularIntake: "Irregular",
refLineDayX: "D{{x}}", refLineDayX: "D{{x}}",
refLineRegularPlanShort: "(Reg.)", refLineRegularPlanShort: "(Base)",
refLineNoDeviationShort: "(Reg.)", // currently the same as above (day# > 1 with curve identical to day1 / regular plan) refLineNoDeviationShort: "(Base)", // currently the same as above (day# > 1 with curve identical to day1 / baseline schedule)
refLineRecoveringShort: "(Rec.)", refLineRecoveringShort: "(Rec.)",
refLineIrregularIntakeShort: "(Ireg.)", refLineIrregularIntakeShort: "(Ireg.)",
refLineDayShort: "D{{x}}", refLineDayShort: "D{{x}}",
@@ -67,7 +92,7 @@ export const en = {
refLineMax: "Max", refLineMax: "Max",
pinChart: "Pin chart to top", pinChart: "Pin chart to top",
unpinChart: "Unpin chart", unpinChart: "Unpin chart",
stickyChartTooltip: "Keep chart visible while scrolling through settings for real-time feedback. Default: off.", stickyChartTooltip: "Keep chart visible while scrolling through settings for real-time feedback.\\n\\n__Default:__ **off**",
chartViewDamphTooltip: "Show only the active metabolite (d-Amphetamine) concentration profile", chartViewDamphTooltip: "Show only the active metabolite (d-Amphetamine) concentration profile",
chartViewLdxTooltip: "Show only the prodrug (Lisdexamfetamine) concentration profile", chartViewLdxTooltip: "Show only the prodrug (Lisdexamfetamine) concentration profile",
chartViewBothTooltip: "Show both d-Amphetamine and Lisdexamfetamine profiles together", chartViewBothTooltip: "Show both d-Amphetamine and Lisdexamfetamine profiles together",
@@ -78,11 +103,13 @@ export const en = {
advancedSettings: "Advanced Settings", advancedSettings: "Advanced Settings",
advancedSettingsWarning: "⚠️ These parameters affect simulation accuracy and may deviate from population averages. Adjust only if you have specific clinical data or research references.", advancedSettingsWarning: "⚠️ These parameters affect simulation accuracy and may deviate from population averages. Adjust only if you have specific clinical data or research references.",
standardVolumeOfDistribution: "Volume of Distribution (Vd)", standardVolumeOfDistribution: "Volume of Distribution (Vd)",
standardVdTooltip: "Defines how drug disperses in body. Adult: 377L (Roberts 2015), Child: ~150-200L. Affects all concentration calculations. Change only for pediatric or specialized simulations. Default: {{standardVdValue}}L ({{standardVdPreset}}).", standardVdTooltip: "Defines how drug disperses in body.\\n\\n__Presets:__\\n• __Adult:__ **377L** (Roberts 2015)\\n• __Child:__ **~150-200L**\\n• __Weight-based:__ **~5.4 L/kg** (intended for adults >18 years based on [population PK analysis](https://pmc.ncbi.nlm.nih.gov/articles/PMC5572767/))\\n\\nAffects all concentration calculations. Change only for pediatric or specialized simulations.\\n\\n__Default:__ **{{standardVdValue}}L** ({{standardVdPreset}})",
standardVdPresetAdult: "Adult (377L)", standardVdPresetAdult: "Adult (377L)",
standardVdPresetChild: "Child (175L)", standardVdPresetChild: "Child (175L)",
standardVdPresetCustom: "Custom", standardVdPresetCustom: "Custom",
standardVdPresetWeightBased: "Weight-Based (~5.4 L/kg)",
customVdValue: "Custom Vd (L)", customVdValue: "Custom Vd (L)",
weightBasedVdInfo: "Weight-based Vd adjusts plasma concentrations based on body weight (~5.4 L/kg).\\n\\nLighter persons → higher peaks, heavier → lower peaks.\\n\\nThis option is intended for adults (>18 years) based on the population PK study. For pediatric patients, use the 'Child' preset.",
xAxisTimeFormat: "Time Format", xAxisTimeFormat: "Time Format",
xAxisFormatContinuous: "Continuous", xAxisFormatContinuous: "Continuous",
xAxisFormatContinuousDesc: "Endless sequence (0h, 6h, 12h...)", xAxisFormatContinuousDesc: "Endless sequence (0h, 6h, 12h...)",
@@ -90,71 +117,75 @@ 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: "Continuously Show Regular Plan", showTemplateDayInChart: "Show Baseline Schedule for Comparison",
showTemplateDayTooltip: "Display the regular medication plan as reference overlay at all times (default: enabled).", showTemplateDayTooltip: "Continue simulating the baseline schedule even when deviations are defined for day 2+. Corresponding plasma concentrations will be shown as additional dashed lines, only if deviating from the baseline schedule.\\n\\n__Default:__ **enabled**",
simulationSettings: "Simulation Settings", simulationSettings: "Simulation Settings",
showDayReferenceLines: "Show Day Separators", showDayReferenceLines: "Show Day Separators",
showDayReferenceLinesTooltip: "Display vertical lines and status indicators separating days (default: enabled).", showDayReferenceLinesTooltip: "Display vertical lines and status indicators separating days.\\n\\n__Default:__ **enabled**", showIntakeTimeLines: "Show Intake Time Markers",
showTherapeuticRangeLines: "Show Therapeutic Range", showIntakeTimeLinesTooltip: "Display vertical dashed lines at intake times with dose index labels.\\n\\n__Default:__ **disabled**", showTherapeuticRangeLines: "Show Therapeutic Range",
showTherapeuticRangeLinesTooltip: "Display horizontal reference lines for therapeutic min/max concentrations (default: enabled).", showTherapeuticRangeLinesTooltip: "Display horizontal reference lines for therapeutic min/max concentrations.\\n\\n__Default:__ **enabled**",
simulationDuration: "Simulation Duration", simulationDuration: "Simulation Duration",
simulationDurationTooltip: "Number of days to simulate. Longer periods allow steady-state observation. Default: {{simulationDays}} days.", simulationDurationTooltip: "Number of days to simulate. Longer periods allow steady-state observation.\\n\\n__Default:__ **{{simulationDays}} days**",
displayedDays: "Visible Days (in Focus)", displayedDays: "Visible Days (in Focus)",
displayedDaysTooltip: "How many days to display on screen at once. Smaller values zoom in on details. Default: {{displayedDays}} day(s).", displayedDaysTooltip: "How many days to display on screen at once. Smaller values zoom in on details.\\n\\n__Default:__ **{{displayedDays}} day(s)**",
yAxisRange: "Y-Axis Range (Concentration Zoom)", yAxisRange: "Y-Axis Range (Concentration Zoom)",
yAxisRangeTooltip: "Manually set vertical axis limits (concentration scale). Leave empty for automatic scaling based on data. Default: auto.", yAxisRangeTooltip: "Manually set vertical axis limits (concentration scale). Leave empty for automatic scaling based on data.\\n\\n__Default:__ **auto**",
yAxisRangeAutoButton: "A", yAxisRangeAutoButton: "A",
yAxisRangeAutoButtonTitle: "Determine range automatically based on data range", yAxisRangeAutoButtonTitle: "Determine range automatically based on data range",
auto: "Auto", auto: "Auto",
therapeuticRange: "Therapeutic Range", therapeuticRange: "Therapeutic Range (d-Amphetamine)",
therapeuticRangeTooltip: "Personalized concentration targets based on YOUR individual response. Set these after observing which levels provide symptom control vs. side effects. Reference ranges (highly variable): Adults ~10-80 ng/mL, Children ~20-120 ng/mL (due to lower body weight/Vd). Leave empty if unsure. Consult your physician.", therapeuticRangeTooltip: "Personalized concentration targets based on **YOUR individual response**.\\n\\nSet these after observing which levels provide symptom control vs. side effects.\\n\\n**Reference ranges** (highly variable):\\n• __Adults:__ **~10-80 ng/mL**\\n• __Children:__ **~20-120 ng/mL** (due to lower body weight/Vd)\\n\\nLeave empty if unsure. ***Consult your physician.***",
dAmphetamineParameters: "d-Amphetamine Parameters", dAmphetamineParameters: "d-Amphetamine Parameters",
halfLife: "Elimination Half-life", halfLife: "Elimination Half-life",
halfLifeTooltip: "Time for body to clear half the d-amphetamine from blood. Affected by urine pH: acidic (<6) → 7-9h, neutral (6-7.5) → 10-12h, alkaline (>7.5) → 13-15h. See [therapeutic reference ranges](https://www.thieme-connect.com/products/ejournals/pdf/10.1055/a-2689-4911.pdf). Default: {{damphHalfLife}}h.", halfLifeTooltip: "Time for body to clear half the d-amphetamine from blood.\\n\\n__Affected by urine pH:__\\n• __Acidic__ (<6) → **7-9h**\\n• __Neutral__ (6-7.5) → **10-12h**\\n• __Alkaline__ (>7.5) → **13-15h**\\n\\n*See* [therapeutic reference ranges](https://www.thieme-connect.com/products/ejournals/pdf/10.1055/a-2689-4911.pdf).\\n\\n__Default:__ **{{damphHalfLife}}h**",
lisdexamfetamineParameters: "Lisdexamfetamine (LDX) Parameters", lisdexamfetamineParameters: "Lisdexamfetamine (LDX) Parameters",
conversionHalfLife: "LDX→d-Amph Conversion Half-life", conversionHalfLife: "LDX→d-Amph Conversion Half-life",
conversionHalfLifeTooltip: "Time for red blood cells to convert half the inactive LDX prodrug into active d-amphetamine. Typical: 0.7-1.2h. Default: {{ldxHalfLife}}h.", conversionHalfLifeTooltip: "Time for red blood cells to convert half the inactive LDX prodrug into active d-amphetamine.\\n\\n__Typical range:__ **0.7-1.2h**.\\n__Default:__ **{{ldxHalfLife}}h**",
absorptionHalfLife: "Absorption Half-life", absorptionHalfLife: "Absorption Half-life",
absorptionHalfLifeTooltip: "Time for intestines to absorb half the LDX from stomach to blood. Delayed by food (~1h shift). Typical: 0.7-1.2h. Default: {{ldxAbsorptionHalfLife}}h.", absorptionHalfLifeTooltip: "Time for intestines to absorb half the LDX from stomach to blood.\\n\\nDelayed by food (**~1h shift**).\\n\\n__Typical range:__ **0.7-1.2h**.\\n__Default:__ **{{ldxAbsorptionHalfLife}}h**",
faster: "(faster >)", faster: "(faster >)",
// Advanced Settings // Advanced Settings
weightBasedVdScaling: "Weight-Based Volume of Distribution", weightBasedVdScaling: "Weight-Based Volume of Distribution",
weightBasedVdTooltip: "Adjusts plasma concentrations based on body weight (proportional to ~5.4 L/kg). Lighter persons → higher peaks, heavier → lower peaks. When disabled, assumes 70 kg adult.", weightBasedVdTooltip: "Adjusts plasma concentrations based on body weight (proportional to **~5.4 L/kg**).\\n\\n__Effects:__\\n• __Lighter persons__***higher*** concentration peaks\\n• __Heavier persons__ → ***lower*** concentration peaks\\n\\n__When disabled:__ assumes **70 kg adult**",
bodyWeight: "Body Weight", bodyWeight: "Body Weight",
bodyWeightTooltip: "Your body weight for concentration scaling. Used to calculate volume of distribution (Vd = weight × 5.4). See [population PK analysis](https://pmc.ncbi.nlm.nih.gov/articles/PMC5572767/). Default: {{bodyWeight}} kg.", bodyWeightTooltip: "Your body weight for concentration scaling.\\n\\nUsed to calculate volume of distribution:\\n**Vd = weight × 5.4**\\n\\nSee [population PK analysis](https://pmc.ncbi.nlm.nih.gov/articles/PMC5572767/).\\n\\n__Default:__ **{{bodyWeight}} kg**",
bodyWeightUnit: "kg", bodyWeightUnit: "kg",
foodEffectEnabled: "Taken With Meal", foodEffectEnabled: "Taken With Meal",
foodEffectDelay: "Food Effect Delay", foodEffectDelay: "Food Effect Delay",
foodEffectTooltip: "High-fat meals delay absorption without changing total exposure. Slows onset of effects (~1h delay). When disabled, assumes fasted state.", foodEffectTooltip: "High-fat meals delay absorption **without changing total exposure**.\\n\\nSlows onset of effects by **~1 hour**.\\n\\nWhen disabled, assumes fasted state.",
tmaxDelay: "Absorption Delay", tmaxDelay: "Absorption Delay",
tmaxDelayTooltip: "Time delay when dose is taken with high-fat meal. Applied using per-dose food toggles (🍴 icon) in schedule. Research shows ~1h delay without peak reduction. See [study](https://pmc.ncbi.nlm.nih.gov/articles/PMC4823324/). Default: {{tmaxDelay}}h.", tmaxDelayTooltip: "Time delay when dose is taken with **high-fat meal**. Applied using per-dose food toggles (🍴 icon) in schedule.\\n\\nResearch shows ~1h delay without peak reduction. *See* [study](https://pmc.ncbi.nlm.nih.gov/articles/PMC4823324/).\\n\\n__Note:__ The high-fat meal used in this study consisted of 1 English muffin with butter, 1 fried egg, 1 slice of American cheese, 1 slice of Canadian bacon, 2 oz (57 g) of hash brown potatoes, and 8 fl oz (240 mL) of whole milk.\\n\\n__Default:__ **{{tmaxDelay}}h**",
tmaxDelayUnit: "h", tmaxDelayUnit: "h",
urinePHTendency: "Urine pH Effects", urinePHTendency: "Urine pH Effects",
urinePHTooltip: "Urine pH affects kidney reabsorption of amphetamine. Enables pH-dependent half-life variation (7-15h range). When disabled, assumes neutral pH (~11h HL).", urinePHTooltip: "Urine pH affects kidney reabsorption of amphetamine.\\n\\n__Effects on elimination:__\\n• __Acidic__ (<6) → ***Faster*** clearance, **t½ ~7-9h**\\n• __Normal__ (6-7.5) → ***Baseline*** elimination **~11h**\\n• __Alkaline__ (>7.5) → ***Slower*** clearance, **t½ ~13-15h**\\n\\n__Typical range:__ **5.5-8.0**\\n\\n__Default:__ **Normal pH** (6-7.5)",
urinePHMode: "pH Effect",
urinePHModeNormal: "Normal (pH 6-7.5, t½ 11h)",
urinePHModeAcidic: "Acidic (pH <6, faster elimination)",
urinePHModeAlkaline: "Alkaline (pH >7.5, slower elimination)",
urinePHValue: "pH Value", urinePHValue: "pH Value",
urinePHValueTooltip: "Your typical urine pH (acidic=faster clearance, alkaline=slower). Default: {{phTendency}}. Range: 5.5-8.0.", urinePHValueTooltip: "Your typical urine pH (acidic=faster clearance, alkaline=slower).\\n\\nRange: **5.5-8.0**.\\n\\n__Default:__ **{{phTendency}}**",
phValue: "pH Value", phValue: "pH Value",
phUnit: "(5.5-8.0)", phUnit: "(5.5-8.0)",
oralBioavailability: "Oral Bioavailability", oralBioavailability: "Oral Bioavailability",
oralBioavailabilityTooltip: "Fraction of LDX dose that reaches bloodstream. See [bioavailability study](https://www.frontiersin.org/journals/pharmacology/articles/10.3389/fphar.2022.881198/full) (FDA label: 96.4%). Rarely needs adjustment unless you have documented absorption issues. Default: {{fOral}} ({{fOralPercent}}%).", oralBioavailabilityTooltip: "Fraction of LDX dose that reaches bloodstream.\\n\\n*See* [bioavailability study](https://www.frontiersin.org/journals/pharmacology/articles/10.3389/fphar.2022.881198/full) — **FDA label: 96.4%**.\\n\\nRarely needs adjustment unless you have documented absorption issues.\\n\\n__Default:__ **{{fOral}} ({{fOralPercent}}%)**",
steadyStateDays: "Medication History", steadyStateDays: "Medication History",
steadyStateDaysTooltip: "Number of prior days on stable medication dose to simulate accumulation/steady-state. Set 0 for \"first day from scratch.\" Default: {{steadyStateDays}} days. Max: 7.", steadyStateDaysTooltip: "Number of prior days on stable medication dose to simulate accumulation/steady-state.\\n\\If this option is disabled, the simulation will begin from day one with no prior medication history. The same applies for the value is **0**.\\n\\nMax: **7 days**.\\n\\n__Default:__ **{{steadyStateDays}} days**.",
// Age-specific pharmacokinetics // Age-specific pharmacokinetics
ageGroup: "Age Group", ageGroup: "Age Group",
ageGroupTooltip: "Pediatric subjects (6-12y) exhibit faster d-amphetamine elimination (t½ ~9h) compared to adults (~11h) due to higher weight-normalized metabolic rate. See [research document](https://git.11001001.org/cbaoth/med-plan-assistant/src/branch/main/docs/2026-01-17_AI-Reseach_SimulatingLDXandD-AmphetaminePlasmaLevels.md#52-pediatric-vs-adult-modeling) Section 5.2. Select 'custom' to use your manually configured half-life. Default: adult.", ageGroupTooltip: "Pediatric subjects (6-12y) exhibit **faster d-amphetamine elimination** (t½ ~9h) compared to adults (~11h) due to higher weight-normalized metabolic rate.\\n\\n*See* [research document](https://git.11001001.org/cbaoth/med-plan-assistant/src/branch/main/docs/2026-01-17_AI-Reseach_SimulatingLDXandD-AmphetaminePlasmaLevels.md#52-pediatric-vs-adult-modeling) *Section 5.2.*\\n\\nSelect 'custom' to use your manually configured half-life.\\n\\n__Default:__ **adult**.",
ageGroupAdult: "Adult (t½ 11h)", ageGroupAdult: "Adult (t½ 11h)",
ageGroupChild: "Child 6-12y (t½ 9h)", ageGroupChild: "Child 6-12y (t½ 9h)",
ageGroupCustom: "Custom (use manual t½)", ageGroupCustom: "Custom (use manual t½)",
// Renal function effects // Renal function effects
renalFunction: "Renal Impairment", renalFunction: "Renal Impairment",
renalFunctionTooltip: "Severe renal impairment extends d-amphetamine half-life by ~50% (from 11h to 16.5h). FDA label recommends dose caps: 50mg for severe impairment, 30mg for ESRD. See [FDA Label Section 8.6](https://www.accessdata.fda.gov/drugsatfda_docs/label/2017/021977s049lbl.pdf) and [research document](https://git.11001001.org/cbaoth/med-plan-assistant/src/branch/main/docs/2026-01-17_AI-Reseach_SimulatingLDXandD-AmphetaminePlasmaLevels.md#82-renal-function) Section 8.2. Default: disabled.", renalFunctionTooltip: "Severe renal impairment extends d-amphetamine half-life by **~50%** (from 11h to 16.5h).\\n\\n__FDA label dose caps:__\\n• __Severe impairment__: **50mg**\\n• __ESRD__: **30mg**\\n*See* [FDA Label Section 8.6](https://www.accessdata.fda.gov/drugsatfda_docs/label/2017/021977s049lbl.pdf) *and* [research document](https://git.11001001.org/cbaoth/med-plan-assistant/src/branch/main/docs/2026-01-17_AI-Reseach_SimulatingLDXandD-AmphetaminePlasmaLevels.md#82-renal-function) *Section 8.2.*\\n\\n__Default:__ **disabled**.",
renalFunctionSeverity: "Impairment Severity", renalFunctionSeverity: "Impairment Severity",
renalFunctionNormal: "Normal (no adjustment)", renalFunctionNormal: "Normal (no adjustment)",
renalFunctionMild: "Mild (no adjustment)", renalFunctionMild: "Mild (no adjustment)",
@@ -163,7 +194,7 @@ export const en = {
resetAllSettings: "Reset All Settings", resetAllSettings: "Reset All Settings",
resetDiagramSettings: "Reset Diagram Settings", resetDiagramSettings: "Reset Diagram Settings",
resetPharmacokineticSettings: "Reset Pharmacokinetic Settings", resetPharmacokineticSettings: "Reset Pharmacokinetic Settings",
resetPlan: "Reset Plan", resetPlan: "Reset Schedule",
// Disclaimer Modal // Disclaimer Modal
disclaimerModalTitle: "Important Medical Disclaimer", disclaimerModalTitle: "Important Medical Disclaimer",
@@ -199,11 +230,13 @@ export const en = {
importSettings: "Import Settings", importSettings: "Import Settings",
exportSelectWhat: "Select what to export:", exportSelectWhat: "Select what to export:",
importSelectWhat: "Select what to import:", importSelectWhat: "Select what to import:",
exportOptionSchedules: "Schedules (Day plans with doses)", exportOptionSchedules: "Schedules (Daily plans with doses)",
exportOptionDiagram: "Diagram Settings (View options, chart display)", exportOptionDiagram: "Diagram Settings (View options, chart display)",
exportOptionSimulation: "Simulation Settings (Duration, range, chart view)", exportOptionSimulation: "Simulation Settings (Duration, range, chart view)",
exportOptionPharmaco: "Pharmacokinetic Settings (Half-lives, therapeutic range)", exportOptionPharmaco: "Pharmacokinetic Settings (Half-lives, therapeutic range)",
exportOptionAdvanced: "Advanced Settings (Weight, food, pH, bioavailability)", exportOptionAdvanced: "Advanced Settings (Weight, food, pH, bioavailability)",
exportOptionOtherData: "Other Data (Theme, collapsed cards, language, disclaimer)",
exportOptionOtherDataTooltip: "UI preferences like theme, collapsed card states, language preference, and disclaimer acceptance. Typically not needed when sharing schedules with others.",
exportButton: "Download Backup File", exportButton: "Download Backup File",
importButton: "Choose File to Import", importButton: "Choose File to Import",
importApplyButton: "Apply Import", importApplyButton: "Apply Import",
@@ -248,12 +281,24 @@ export const en = {
closeDataManagement: "Close", closeDataManagement: "Close",
pasteContentTooLarge: "Content too large (max. 5000 characters)", pasteContentTooLarge: "Content too large (max. 5000 characters)",
// Delete Data
deleteSpecificData: "Delete Specific Data",
deleteSpecificDataTooltip: "Permanently delete selected data categories from your device. This operation cannot be undone.",
deleteSelectWhat: "Select what to delete:",
deleteDataWarning: "⚠️ Warning: Deletion is permanent and cannot be undone. Deleted data will be reset to default values.",
deleteDataButton: "Delete Selected Data",
deleteNoOptionsSelected: "Please select at least one category to delete.",
deleteDataConfirmTitle: "Are you sure you want to permanently delete the following data?",
deleteDataConfirmWarning: "This action cannot be undone. Deleted data will be reset to factory defaults.",
deleteDataSuccess: "Selected data has been deleted successfully.",
// Footer disclaimer // Footer disclaimer
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 // Number input field
buttonClear: "Clear field", buttonClear: "Clear field",
buttonResetToDefault: "Reset to default",
// Field validation - Errors // Field validation - Errors
errorNumberRequired: "⛔ Please enter a valid number.", errorNumberRequired: "⛔ Please enter a valid number.",
@@ -264,21 +309,30 @@ export const en = {
errorAbsorptionRateRequired: "⛔ Absorption rate is required.", errorAbsorptionRateRequired: "⛔ Absorption rate is required.",
errorTherapeuticRangeMinRequired: "⛔ Minimum therapeutic range is required.", errorTherapeuticRangeMinRequired: "⛔ Minimum therapeutic range is required.",
errorTherapeuticRangeMaxRequired: "⛔ Maximum therapeutic range is required.", errorTherapeuticRangeMaxRequired: "⛔ Maximum therapeutic range is required.",
errorTherapeuticRangeInvalid: "⛔ Maximum must be greater than minimum.",
errorYAxisRangeInvalid: "⚠️ Maximum must be greater than minimum.",
errorEliminationHalfLifeRequired: "⛔ Elimination half-life is required.", errorEliminationHalfLifeRequired: "⛔ Elimination half-life is required.",
// Field validation - Warnings // Field validation - Warnings
warningDuplicateTime: "⚠️ Multiple doses at same time.", warningDuplicateTime: "⚠️ Multiple doses at same time.",
warningZeroDose: "⚠️ Zero dose has no effect on simulation.", warningZeroDose: "⚠️ Zero dose has no effect on simulation.",
warningAbsorptionOutOfRange: "⚠️ Typical range: 0.7-1.2h. Current value may be outside clinical norms.", warningAbsorptionOutOfRange: "⚠️ Current value may be outside clinical norms.\\n\\n__Typical range:__ **0.7-1.2h**.",
warningConversionOutOfRange: "⚠️ Typical range: 0.7-1.2h. Current value may be outside clinical norms.", warningConversionOutOfRange: "⚠️ Current value may be outside clinical norms.\\n\\n__Typical range:__ **0.7-1.2h**.",
warningEliminationOutOfRange: "⚠️ Typical range: 9-12h (normal pH). Extended range 7-15h (pH effects). Current value is unusual.", warningEliminationOutOfRange: "⚠️ Current value may be outside clinical norms.\\n\\n__Typical range:__ **9-12h** (normal pH).\\nExtended range 7-15h (pH effects).",
warningDoseAbove70mg: "⚠️ FDA-approved maximum: 70 mg. Higher doses lack safety data and increase cardiovascular risk.", warningDoseAbove70mg: "⚠️ Higher doses lack safety data and increase cardiovascular risk.\\n\\n__FDA-approved maximum:__ **70 mg**.\\n\\nConsult your physician before exceeding this dose.",
warningDailyTotalAbove70mg: "⚠️ **Daily total exceeds recommended maximum.**\\n\\n__FDA-approved maximum:__ **70 mg/day**.\\nYour daily total: **{{total}} mg**.\\nConsult your physician before exceeding this dose.",
errorDailyTotalAbove200mg: "⛔ **Daily total far exceeds safe limits!**\\n\\nYour daily total **{{total}} mg** exceeds 200 mg/day which is **significantly beyond FDA-approved limits**. *Please consult your physician.*",
// Time picker // Time picker
timePickerHour: "Hour", timePickerHour: "Hour",
timePickerMinute: "Minute", timePickerMinute: "Minute",
timePickerApply: "Apply", timePickerApply: "Apply",
timePickerCancel: "Cancel",
// Input field placeholders
min: "Min",
max: "Max",
// Sorting // Sorting
sortByTime: "Sort by time", sortByTime: "Sort by time",
@@ -286,13 +340,13 @@ export const en = {
sortByTimeSorted: "Doses are sorted chronologically.", sortByTimeSorted: "Doses are sorted chronologically.",
// Day-based schedule // Day-based schedule
regularPlan: "Regular Plan", regularPlan: "Baseline Schedule",
deviatingPlan: "Deviation from Plan", deviatingPlan: "Deviation from Schedule",
alternativePlan: "Alternative Plan", alternativePlan: "Alternative Schedule",
regularPlanOverlay: "Regular", regularPlanOverlay: "Baseline",
dayNumber: "Day {{number}}", dayNumber: "Day {{number}}",
cloneDay: "Clone day", cloneDay: "Clone day",
addDay: "Add day", addDay: "Add day (alternative schedule)",
addDose: "Add dose", addDose: "Add dose",
removeDose: "Remove dose", removeDose: "Remove dose",
removeDay: "Remove day", removeDay: "Remove day",
@@ -300,17 +354,17 @@ export const en = {
expandDay: "Expand day", expandDay: "Expand day",
dose: "dose", dose: "dose",
doses: "doses", doses: "doses",
comparedToRegularPlan: "compared to regular plan", comparedToRegularPlan: "compared to baseline schedule",
time: "Time", time: "Time of Intake",
ldx: "LDX", ldx: "LDX",
damph: "d-amph", damph: "d-amph",
// URL sharing // URL sharing
sharePlan: "Share Plan", sharePlan: "Share Schedule",
viewingSharedPlan: "Viewing shared plan", viewingSharedPlan: "Viewing shared schedule",
saveAsMyPlan: "Save as My Plan", saveAsMyPlan: "Save as My Schedule",
discardSharedPlan: "Discard", discardSharedPlan: "Discard",
planCopiedToClipboard: "Plan link copied to clipboard!" planCopiedToClipboard: "Schedule link copied to clipboard!"
}; };
export default en; export default en;

View File

@@ -48,6 +48,10 @@
--accent-foreground: 0 0% 90%; --accent-foreground: 0 0% 90%;
--destructive: 0 84% 60%; --destructive: 0 84% 60%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--bubble-error: 0 84% 60%;
--bubble-error-foreground: 0 0% 98%;
--bubble-warning: 42 100% 60%;
--bubble-warning-foreground: 0 0% 98%;
--border: 0 0% 25%; --border: 0 0% 25%;
--input: 0 0% 25%; --input: 0 0% 25%;
--ring: 0 0% 40%; --ring: 0 0% 40%;
@@ -68,3 +72,82 @@
font-feature-settings: "rlig" 1, "calt" 1; font-feature-settings: "rlig" 1, "calt" 1;
} }
} }
@layer components {
/* Error message bubble - validation popups */
.error-bubble {
@apply bg-[hsl(var(--background))] text-[hsl(var(--foreground))] border border-red-500 dark:border-red-500;
}
/* Warning message bubble - validation popups */
.warning-bubble {
@apply bg-[hsl(var(--background))] text-[hsl(var(--foreground))] border border-amber-500 dark:border-amber-500;
}
/* Error border - for input fields with errors */
.error-border {
@apply !border-red-500;
}
/* Warning border - for input fields with warnings */
.warning-border {
@apply !border-amber-500;
}
/* Info border - for input fields with informational messages */
.info-border {
@apply !border-blue-500;
}
/* Error background box - for static error/warning sections */
.error-bg-box {
@apply bg-[hsl(var(--background))] border border-red-500 dark:border-red-500;
}
/* Warning background box - for static warning sections */
.warning-bg-box {
@apply bg-[hsl(var(--background))] border border-amber-500 dark:border-amber-500;
}
/* Info background box - for informational sections */
.info-bg-box {
@apply bg-[hsl(var(--background))] border border-blue-500 dark:border-blue-500;
}
/* Error text - for inline error text */
.error-text {
@apply text-[hsl(var(--foreground))];
}
/* Warning text - for inline warning text */
.warning-text {
@apply text-[hsl(var(--foreground))];
}
/* Info text - for inline info text */
.info-text {
@apply text-[hsl(var(--foreground))];
}
/* Badge variants for validation states */
.badge-error {
@apply border-red-500 bg-red-500/20 text-red-700 dark:text-red-300;
}
.badge-warning {
@apply border-amber-500 bg-amber-500/20 text-amber-700 dark:text-amber-300;
}
.badge-info {
@apply border-blue-500 bg-blue-500/20 text-blue-700 dark:text-blue-300;
}
/* Badge variants for trend indicators */
.badge-trend-up {
@apply bg-blue-100 dark:bg-blue-900/60 text-blue-700 dark:text-blue-200;
}
.badge-trend-down {
@apply bg-orange-100 dark:bg-orange-900/60 text-orange-700 dark:text-orange-200;
}
}

View File

@@ -0,0 +1,230 @@
/**
* Content Formatting Utilities
*
* Provides markdown-style formatting capabilities for various UI content including:
* - Tooltips
* - Error/warning messages
* - Info boxes
* - Help text
*
* Supported formatting (processed in this order):
* 1. Links: [text](url)
* 2. Bold+Italic: ***text***
* 3. Bold: **text**
* 4. Italic: *text*
* 5. Underline: __text__
* 6. Line breaks: \n
*
* @author Andreas Weyer
* @license MIT
*/
import * as React from 'react';
/**
* Renders formatted formatContent with markdown-style formatting support.
* Can be used for tooltips, error messages, info boxes, and other UI text.
*
* Processing order: Links → Bold+Italic (***) → Bold (**) → Italic (*) → Underline (__) → Line breaks (\n)
*
* @example
* ```typescript
* // In tooltip
* formatContent("See [study](https://example.com)\\n__Important:__ **Take with food**.")
*
* // In error message
* formatContent("**Error:** Value must be between *5* and *50*.")
*
* // In info box
* formatContent("***Note:*** This setting affects accuracy.\\n\\nSee [docs](https://example.com).")
* ```
*
* @param text - The text to format with markdown-style syntax
* @returns Formatted React nodes ready for rendering
*/
export const formatContent = (text: string): React.ReactNode => {
// Helper to process text segments with bold/italic/underline formatting
const processFormatting = (segment: string, keyPrefix: string): React.ReactNode[] => {
const parts: React.ReactNode[] = [];
let remaining = segment;
let partIndex = 0;
// Process bold+italic first (***text***)
const boldItalicRegex = /\*\*\*([^*]+)\*\*\*/g;
let lastIdx = 0;
let boldItalicMatch;
while ((boldItalicMatch = boldItalicRegex.exec(remaining)) !== null) {
// Add text before bold+italic
if (boldItalicMatch.index > lastIdx) {
const beforeBoldItalic = remaining.substring(lastIdx, boldItalicMatch.index);
parts.push(...processBoldItalicAndUnderline(beforeBoldItalic, `${keyPrefix}-bi${partIndex++}`));
}
// Add bold+italic text
parts.push(
<strong key={`${keyPrefix}-bolditalic-${partIndex++}`} className="font-semibold italic">
{boldItalicMatch[1]}
</strong>
);
lastIdx = boldItalicRegex.lastIndex;
}
// Add remaining text with bold/italic/underline processing
if (lastIdx < remaining.length) {
parts.push(...processBoldItalicAndUnderline(remaining.substring(lastIdx), `${keyPrefix}-bi${partIndex++}`));
}
return parts.length > 0 ? parts : [remaining];
};
// Helper to process bold/italic/underline (after bold+italic ***)
const processBoldItalicAndUnderline = (segment: string, keyPrefix: string): React.ReactNode[] => {
const parts: React.ReactNode[] = [];
const boldRegex = /\*\*([^*]+)\*\*/g;
let lastIdx = 0;
let boldMatch;
while ((boldMatch = boldRegex.exec(segment)) !== null) {
// Add text before bold
if (boldMatch.index > lastIdx) {
const beforeBold = segment.substring(lastIdx, boldMatch.index);
parts.push(...processItalicAndUnderline(beforeBold, `${keyPrefix}-b${lastIdx}`));
}
// Add bold text
parts.push(
<strong key={`${keyPrefix}-bold-${boldMatch.index}`} className="font-semibold">
{boldMatch[1]}
</strong>
);
lastIdx = boldRegex.lastIndex;
}
// Add remaining text with italic/underline processing
if (lastIdx < segment.length) {
parts.push(...processItalicAndUnderline(segment.substring(lastIdx), `${keyPrefix}-b${lastIdx}`));
}
return parts.length > 0 ? parts : [segment];
};
// Helper to process italic and underline (*text* and __text__)
const processItalicAndUnderline = (segment: string, keyPrefix: string): React.ReactNode[] => {
const parts: React.ReactNode[] = [];
// Match single * that's not part of ** or inside links
const italicRegex = /(?<!\*)\*(?!\*)([^*]+)\*(?!\*)/g;
let lastIdx = 0;
let italicMatch;
while ((italicMatch = italicRegex.exec(segment)) !== null) {
// Add text before italic (process underline in it)
if (italicMatch.index > lastIdx) {
const beforeItalic = segment.substring(lastIdx, italicMatch.index);
parts.push(...processUnderline(beforeItalic, `${keyPrefix}-i${lastIdx}`));
}
// Add italic text
parts.push(
<em key={`${keyPrefix}-italic-${italicMatch.index}`} className="italic">
{italicMatch[1]}
</em>
);
lastIdx = italicRegex.lastIndex;
}
// Add remaining text with underline processing
if (lastIdx < segment.length) {
parts.push(...processUnderline(segment.substring(lastIdx), `${keyPrefix}-i${lastIdx}`));
}
return parts.length > 0 ? parts : [segment];
};
// Helper to process underline (__text__) - final level of formatting
const processUnderline = (segment: string, keyPrefix: string): React.ReactNode[] => {
const parts: React.ReactNode[] = [];
const underlineRegex = /__([^_]+)__/g;
let lastIdx = 0;
let underlineMatch;
while ((underlineMatch = underlineRegex.exec(segment)) !== null) {
// Add text before underline (plain text)
if (underlineMatch.index > lastIdx) {
parts.push(segment.substring(lastIdx, underlineMatch.index));
}
// Add underlined text
parts.push(
<span key={`${keyPrefix}-underline-${underlineMatch.index}`} className="underline">
{underlineMatch[1]}
</span>
);
lastIdx = underlineRegex.lastIndex;
}
// Add remaining plain text
if (lastIdx < segment.length) {
parts.push(segment.substring(lastIdx));
}
return parts.length > 0 ? parts : [segment];
};
// Split by line breaks first
const lines = text.split('\\n');
const result: React.ReactNode[] = [];
lines.forEach((line, lineIndex) => {
const lineParts: React.ReactNode[] = [];
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match;
while ((match = linkRegex.exec(line)) !== null) {
// Add text before link with formatting
if (match.index > lastIndex) {
const beforeLink = line.substring(lastIndex, match.index);
lineParts.push(...processFormatting(beforeLink, `line${lineIndex}-seg${lastIndex}`));
}
// Add link
lineParts.push(
<a
key={`line${lineIndex}-link-${match.index}`}
href={match[2]}
target="_blank"
rel="noopener noreferrer"
className="underline italic text-yellow-300 hover:text-yellow-200 cursor-pointer"
>
{match[1]}
</a>
);
lastIndex = linkRegex.lastIndex;
}
// Add remaining text with formatting
if (lastIndex < line.length) {
const remaining = line.substring(lastIndex);
lineParts.push(...processFormatting(remaining, `line${lineIndex}-seg${lastIndex}`));
}
// Add line content
if (lineParts.length > 0) {
result.push(...lineParts);
} else {
result.push(line);
}
// Add line break if not the last line
if (lineIndex < lines.length - 1) {
result.push(<br key={`br-${lineIndex}`} />);
}
});
return result.length > 0 ? result : text;
};
/**
* Alias for renderContent for use in non-tooltip contexts (error messages, info boxes, etc.).
* Provides the same markdown-style formatting capabilities.
*
* @param text - The text to format with markdown-style syntax
* @returns Formatted React nodes ready for rendering
*/
export const formatText = formatContent; // Alias for non-tooltip contexts

View File

@@ -8,18 +8,20 @@
* @license MIT * @license MIT
*/ */
import { AppState, getDefaultState } from '../constants/defaults'; import { AppState, getDefaultState, MAX_PROFILES, type ScheduleProfile } from '../constants/defaults';
export interface ExportData { export interface ExportData {
version: string; version: string;
exportDate: string; exportDate: string;
appVersion: string; appVersion: string;
data: { data: {
schedules?: AppState['days']; schedules?: ScheduleProfile[]; // Schedule configurations (profile-based)
profiles?: ScheduleProfile[]; // Legacy: backward compatibility (renamed to schedules)
diagramSettings?: { diagramSettings?: {
showDayTimeOnXAxis: AppState['uiSettings']['showDayTimeOnXAxis']; showDayTimeOnXAxis: AppState['uiSettings']['showDayTimeOnXAxis'];
showTemplateDay: AppState['uiSettings']['showTemplateDay']; showTemplateDay: AppState['uiSettings']['showTemplateDay'];
showDayReferenceLines: AppState['uiSettings']['showDayReferenceLines']; showDayReferenceLines: AppState['uiSettings']['showDayReferenceLines'];
showIntakeTimeLines: AppState['uiSettings']['showIntakeTimeLines'];
showTherapeuticRange: AppState['uiSettings']['showTherapeuticRange']; showTherapeuticRange: AppState['uiSettings']['showTherapeuticRange'];
stickyChart: AppState['uiSettings']['stickyChart']; stickyChart: AppState['uiSettings']['stickyChart'];
}; };
@@ -37,15 +39,29 @@ export interface ExportData {
doseIncrement: AppState['doseIncrement']; doseIncrement: AppState['doseIncrement'];
}; };
advancedSettings?: AppState['pkParams']['advanced']; advancedSettings?: AppState['pkParams']['advanced'];
otherData?: {
theme?: AppState['uiSettings']['theme'];
settingsCardStates?: any;
dayScheduleCollapsedStates?: any;
language?: string;
disclaimerAccepted?: boolean;
};
}; };
} }
export interface ExportOptions { export interface ExportOptions {
includeSchedules: boolean; includeSchedules: boolean;
exportAllProfiles?: boolean; // If true, export all profiles; if false, export only active profile
restoreExamples?: boolean; // If true, restore example profiles when deleting schedules
includeDiagramSettings: boolean; includeDiagramSettings: boolean;
includeSimulationSettings: boolean; includeSimulationSettings: boolean;
includePharmacoSettings: boolean; includePharmacoSettings: boolean;
includeAdvancedSettings: boolean; includeAdvancedSettings: boolean;
includeOtherData: boolean;
}
export interface ImportOptions {
mergeProfiles?: boolean; // If true, merge imported profiles with existing; if false, replace all
} }
export interface ImportValidationResult { export interface ImportValidationResult {
@@ -74,7 +90,26 @@ export const exportSettings = (
}; };
if (options.includeSchedules) { if (options.includeSchedules) {
exportData.data.schedules = appState.days; if (options.exportAllProfiles) {
// Export all schedules
exportData.data.schedules = appState.profiles;
} else {
// Export only active schedule
const activeProfile = appState.profiles.find(p => p.id === appState.activeProfileId);
if (activeProfile) {
exportData.data.schedules = [activeProfile];
} else {
// Fallback: create schedule from current days
const now = new Date().toISOString();
exportData.data.schedules = [{
id: `profile-export-${Date.now()}`,
name: 'Exported Schedule',
days: appState.days,
createdAt: now,
modifiedAt: now
}];
}
}
} }
if (options.includeDiagramSettings) { if (options.includeDiagramSettings) {
@@ -82,6 +117,7 @@ export const exportSettings = (
showDayTimeOnXAxis: appState.uiSettings.showDayTimeOnXAxis, showDayTimeOnXAxis: appState.uiSettings.showDayTimeOnXAxis,
showTemplateDay: appState.uiSettings.showTemplateDay, showTemplateDay: appState.uiSettings.showTemplateDay,
showDayReferenceLines: appState.uiSettings.showDayReferenceLines ?? true, showDayReferenceLines: appState.uiSettings.showDayReferenceLines ?? true,
showIntakeTimeLines: appState.uiSettings.showIntakeTimeLines ?? false,
showTherapeuticRange: appState.uiSettings.showTherapeuticRange ?? true, showTherapeuticRange: appState.uiSettings.showTherapeuticRange ?? true,
stickyChart: appState.uiSettings.stickyChart, stickyChart: appState.uiSettings.stickyChart,
}; };
@@ -111,6 +147,21 @@ export const exportSettings = (
exportData.data.advancedSettings = appState.pkParams.advanced; exportData.data.advancedSettings = appState.pkParams.advanced;
} }
if (options.includeOtherData) {
const settingsCardStates = localStorage.getItem('settingsCardStates_v1');
const dayScheduleCollapsedStates = localStorage.getItem('dayScheduleCollapsedDays_v1');
const language = localStorage.getItem('medPlanAssistant_language');
const disclaimerAccepted = localStorage.getItem('medPlanDisclaimerAccepted_v1');
exportData.data.otherData = {
theme: appState.uiSettings.theme,
settingsCardStates: settingsCardStates ? JSON.parse(settingsCardStates) : undefined,
dayScheduleCollapsedStates: dayScheduleCollapsedStates ? JSON.parse(dayScheduleCollapsedStates) : undefined,
language: language || undefined,
disclaimerAccepted: disclaimerAccepted === 'true',
};
}
return exportData; return exportData;
}; };
@@ -165,31 +216,68 @@ export const validateImportData = (data: any): ImportValidationResult => {
const importData = data.data; const importData = data.data;
// Validate schedules // Validate schedules (current profile-based format)
if (importData.schedules !== undefined) { if (importData.schedules !== undefined) {
if (!Array.isArray(importData.schedules)) { if (!Array.isArray(importData.schedules)) {
result.errors.push('Schedules: Invalid format (expected array)'); result.errors.push('Schedules: Invalid format (expected array)');
result.isValid = false; result.isValid = false;
} else { } else {
// Check for required fields in schedules // Check for required fields in schedule profiles
importData.schedules.forEach((day: any, index: number) => { importData.schedules.forEach((profile: any, index: number) => {
if (!profile.id || !profile.name || !Array.isArray(profile.days)) {
result.warnings.push(`Schedule ${index + 1}: Missing required fields (id, name, or days)`);
result.hasMissingFields = true;
}
// Validate days within schedule
profile.days?.forEach((day: any, dayIndex: number) => {
if (!day.id || !Array.isArray(day.doses)) { if (!day.id || !Array.isArray(day.doses)) {
result.warnings.push(`Schedule day ${index + 1}: Missing required fields`); result.warnings.push(`Schedule ${index + 1}, day ${dayIndex + 1}: Missing required fields`);
result.hasMissingFields = true; result.hasMissingFields = true;
} }
day.doses?.forEach((dose: any, doseIndex: number) => { day.doses?.forEach((dose: any, doseIndex: number) => {
if (!dose.id || dose.time === undefined || dose.ldx === undefined) { if (!dose.id || dose.time === undefined || dose.ldx === undefined) {
result.warnings.push(`Schedule day ${index + 1}, dose ${doseIndex + 1}: Missing required fields`); result.warnings.push(`Schedule ${index + 1}, day ${dayIndex + 1}, dose ${doseIndex + 1}: Missing required fields`);
result.hasMissingFields = true; result.hasMissingFields = true;
} }
}); });
}); });
});
}
}
// Validate profiles (legacy backward-compat: treat old 'profiles' key as schedules)
if (importData.profiles !== undefined) {
result.warnings.push('Using legacy "profiles" key - please re-export with current version');
if (!Array.isArray(importData.profiles)) {
result.errors.push('Profiles: Invalid format (expected array)');
result.isValid = false;
} else {
// Check for required fields in profiles
importData.profiles.forEach((profile: any, index: number) => {
if (!profile.id || !profile.name || !Array.isArray(profile.days)) {
result.warnings.push(`Profile ${index + 1}: Missing required fields (id, name, or days)`);
result.hasMissingFields = true;
}
// Validate days within profile
profile.days?.forEach((day: any, dayIndex: number) => {
if (!day.id || !Array.isArray(day.doses)) {
result.warnings.push(`Profile ${index + 1}, day ${dayIndex + 1}: Missing required fields`);
result.hasMissingFields = true;
}
day.doses?.forEach((dose: any, doseIndex: number) => {
if (!dose.id || dose.time === undefined || dose.ldx === undefined) {
result.warnings.push(`Profile ${index + 1}, day ${dayIndex + 1}, dose ${doseIndex + 1}: Missing required fields`);
result.hasMissingFields = true;
}
});
});
});
} }
} }
// Validate diagram settings // Validate diagram settings
if (importData.diagramSettings !== undefined) { if (importData.diagramSettings !== undefined) {
const validFields = ['showDayTimeOnXAxis', 'showTemplateDay', 'showDayReferenceLines', 'showTherapeuticRange', 'stickyChart']; const validFields = ['showDayTimeOnXAxis', 'showTemplateDay', 'showDayReferenceLines', 'showIntakeTimeLines', 'showTherapeuticRange', 'stickyChart'];
const importedFields = Object.keys(importData.diagramSettings); const importedFields = Object.keys(importData.diagramSettings);
const unknownFields = importedFields.filter(f => !validFields.includes(f)); const unknownFields = importedFields.filter(f => !validFields.includes(f));
if (unknownFields.length > 0) { if (unknownFields.length > 0) {
@@ -219,7 +307,7 @@ export const validateImportData = (data: any): ImportValidationResult => {
// Validate advanced settings // Validate advanced settings
if (importData.advancedSettings !== undefined) { if (importData.advancedSettings !== undefined) {
const validCategories = ['standardVd', 'weightBasedVd', 'foodEffect', 'urinePh', 'fOral', 'steadyStateDays', 'ageGroup', 'renalFunction']; const validCategories = ['standardVd', 'foodEffect', 'urinePh', 'fOral', 'steadyStateDays', 'ageGroup', 'renalFunction'];
const importedCategories = Object.keys(importData.advancedSettings); const importedCategories = Object.keys(importData.advancedSettings);
const unknownCategories = importedCategories.filter(c => !validCategories.includes(c)); const unknownCategories = importedCategories.filter(c => !validCategories.includes(c));
if (unknownCategories.length > 0) { if (unknownCategories.length > 0) {
@@ -228,30 +316,135 @@ export const validateImportData = (data: any): ImportValidationResult => {
} }
} }
// Validate other data
if (importData.otherData !== undefined) {
const validFields = ['theme', 'settingsCardStates', 'dayScheduleCollapsedStates', 'language', 'disclaimerAccepted'];
const importedFields = Object.keys(importData.otherData);
const unknownFields = importedFields.filter(f => !validFields.includes(f));
if (unknownFields.length > 0) {
result.warnings.push(`Other data: Unknown fields found (${unknownFields.join(', ')})`);
result.hasUnknownFields = true;
}
}
return result; return result;
}; };
/**
* Resolve name conflicts by appending a numeric suffix
*/
const resolveProfileNameConflict = (name: string, existingNames: string[]): string => {
let finalName = name;
let suffix = 2;
const existingNamesLower = existingNames.map(n => n.toLowerCase());
while (existingNamesLower.includes(finalName.toLowerCase())) {
finalName = `${name} (${suffix})`;
suffix++;
}
return finalName;
};
/** /**
* Import validated data into app state * Import validated data into app state
*/ */
export const importSettings = ( export const importSettings = (
currentState: AppState, currentState: AppState,
importData: ExportData['data'], importData: ExportData['data'],
options: ExportOptions options: ExportOptions,
importOptions: ImportOptions = {}
): Partial<AppState> => { ): Partial<AppState> => {
const newState: Partial<AppState> = {}; const newState: Partial<AppState> = {};
if (options.includeSchedules && importData.schedules) { if (options.includeSchedules) {
newState.days = importData.schedules.map(day => ({ // Handle schedules (current profile-based format)
...day, if (importData.schedules && importData.schedules.length > 0) {
// Ensure all required fields exist const mergeMode = importOptions.mergeProfiles ?? false;
doses: day.doses.map(dose => ({
id: dose.id || `dose-${Date.now()}-${Math.random()}`, if (mergeMode) {
time: dose.time || '12:00', // Merge: add imported schedules to existing ones
ldx: dose.ldx || '0', const existingProfiles = currentState.profiles || [];
damph: dose.damph, const existingNames = existingProfiles.map(p => p.name);
}))
// Check if merge would exceed maximum schedules
if (existingProfiles.length + importData.schedules.length > MAX_PROFILES) {
throw new Error(`Cannot merge: Would exceed maximum of ${MAX_PROFILES} schedules. Please delete some schedules first.`);
}
// Process imported schedules
const now = new Date().toISOString();
const newProfiles = importData.schedules.map(profile => {
// Resolve name conflicts
const resolvedName = resolveProfileNameConflict(profile.name, existingNames);
existingNames.push(resolvedName); // Track for next iteration
return {
...profile,
id: `profile-import-${Date.now()}-${Math.random()}`, // New ID
name: resolvedName,
modifiedAt: now
};
});
newState.profiles = [...existingProfiles, ...newProfiles];
// Keep active profile unchanged
newState.activeProfileId = currentState.activeProfileId;
} else {
// Replace: overwrite all schedules
const now = new Date().toISOString();
newState.profiles = importData.schedules.map((profile, index) => ({
...profile,
id: `profile-import-${Date.now()}-${index}`, // Regenerate IDs
modifiedAt: now
})); }));
// Set first imported schedule as active
newState.activeProfileId = newState.profiles[0].id;
newState.days = newState.profiles[0].days;
}
}
// Handle legacy 'profiles' key (backward compatibility - renamed to schedules)
else if (importData.profiles && importData.profiles.length > 0) {
// Same logic as above but with legacy key
const mergeMode = importOptions.mergeProfiles ?? false;
if (mergeMode) {
const existingProfiles = currentState.profiles || [];
const existingNames = existingProfiles.map(p => p.name);
if (existingProfiles.length + importData.profiles.length > MAX_PROFILES) {
throw new Error(`Cannot merge: Would exceed maximum of ${MAX_PROFILES} schedules.`);
}
const now = new Date().toISOString();
const newProfiles = importData.profiles.map(profile => {
const resolvedName = resolveProfileNameConflict(profile.name, existingNames);
existingNames.push(resolvedName);
return {
...profile,
id: `profile-import-${Date.now()}-${Math.random()}`,
name: resolvedName,
modifiedAt: now
};
});
newState.profiles = [...existingProfiles, ...newProfiles];
newState.activeProfileId = currentState.activeProfileId;
} else {
const now = new Date().toISOString();
newState.profiles = importData.profiles.map((profile, index) => ({
...profile,
id: `profile-import-${Date.now()}-${index}`,
modifiedAt: now
}));
newState.activeProfileId = newState.profiles[0].id;
newState.days = newState.profiles[0].days;
}
}
} }
if (options.includeDiagramSettings && importData.diagramSettings) { if (options.includeDiagramSettings && importData.diagramSettings) {
@@ -294,6 +487,149 @@ export const importSettings = (
}; };
} }
if (options.includeOtherData && importData.otherData) {
// Update theme in uiSettings
if (importData.otherData.theme !== undefined) {
if (!newState.uiSettings) {
newState.uiSettings = { ...currentState.uiSettings };
}
newState.uiSettings.theme = importData.otherData.theme;
}
// Update localStorage-only settings
if (importData.otherData.settingsCardStates !== undefined) {
localStorage.setItem('settingsCardStates_v1', JSON.stringify(importData.otherData.settingsCardStates));
}
if (importData.otherData.dayScheduleCollapsedStates !== undefined) {
localStorage.setItem('dayScheduleCollapsedDays_v1', JSON.stringify(importData.otherData.dayScheduleCollapsedStates));
}
if (importData.otherData.language !== undefined) {
localStorage.setItem('medPlanAssistant_language', importData.otherData.language);
}
if (importData.otherData.disclaimerAccepted !== undefined) {
localStorage.setItem('medPlanDisclaimerAccepted_v1', importData.otherData.disclaimerAccepted ? 'true' : 'false');
}
}
return newState;
};
/**
* Delete selected data categories from localStorage and return updated state
* @param currentState Current application state
* @param options Which categories to delete
* @returns Partial state with defaults for deleted categories
*/
export const deleteSelectedData = (
currentState: AppState,
options: ExportOptions
): Partial<AppState> => {
const defaults = getDefaultState();
const newState: Partial<AppState> = {};
// Track if main localStorage should be removed
let shouldRemoveMainStorage = false;
if (options.includeSchedules) {
// Delete all profiles and optionally restore examples
const defaults = getDefaultState();
const now = new Date().toISOString();
if (options.restoreExamples) {
// Restore factory default example profiles
newState.profiles = defaults.profiles;
newState.activeProfileId = defaults.activeProfileId;
newState.days = defaults.days;
} else {
// Create a single blank profile
newState.profiles = [{
id: `profile-blank-${Date.now()}`,
name: 'Default',
days: [
{
id: 'day-template',
isTemplate: true,
doses: [
{ id: 'dose-default', time: '08:00', ldx: '30' }
]
}
],
createdAt: now,
modifiedAt: now
}];
newState.activeProfileId = newState.profiles[0].id;
newState.days = newState.profiles[0].days;
}
shouldRemoveMainStorage = true;
}
if (options.includeDiagramSettings) {
if (!newState.uiSettings) {
newState.uiSettings = { ...currentState.uiSettings };
}
// Reset diagram settings to defaults
newState.uiSettings.showDayTimeOnXAxis = defaults.uiSettings.showDayTimeOnXAxis;
newState.uiSettings.showTemplateDay = defaults.uiSettings.showTemplateDay;
newState.uiSettings.showDayReferenceLines = defaults.uiSettings.showDayReferenceLines;
newState.uiSettings.showIntakeTimeLines = defaults.uiSettings.showIntakeTimeLines;
newState.uiSettings.showTherapeuticRange = defaults.uiSettings.showTherapeuticRange;
newState.uiSettings.stickyChart = defaults.uiSettings.stickyChart;
shouldRemoveMainStorage = true;
}
if (options.includeSimulationSettings) {
if (!newState.uiSettings) {
newState.uiSettings = { ...currentState.uiSettings };
}
// Reset simulation settings to defaults
newState.uiSettings.simulationDays = defaults.uiSettings.simulationDays;
newState.uiSettings.displayedDays = defaults.uiSettings.displayedDays;
newState.uiSettings.yAxisMin = defaults.uiSettings.yAxisMin;
newState.uiSettings.yAxisMax = defaults.uiSettings.yAxisMax;
newState.uiSettings.chartView = defaults.uiSettings.chartView;
newState.uiSettings.steadyStateDaysEnabled = defaults.uiSettings.steadyStateDaysEnabled;
shouldRemoveMainStorage = true;
}
if (options.includePharmacoSettings) {
// Reset pharmacokinetic settings to defaults
newState.pkParams = {
...currentState.pkParams,
ldx: defaults.pkParams.ldx,
damph: defaults.pkParams.damph,
};
newState.therapeuticRange = defaults.therapeuticRange;
newState.doseIncrement = defaults.doseIncrement;
shouldRemoveMainStorage = true;
}
if (options.includeAdvancedSettings) {
if (!newState.pkParams) {
newState.pkParams = { ...currentState.pkParams };
}
// Reset advanced settings to defaults
newState.pkParams.advanced = defaults.pkParams.advanced;
shouldRemoveMainStorage = true;
}
if (options.includeOtherData) {
// Reset theme to default
if (!newState.uiSettings) {
newState.uiSettings = { ...currentState.uiSettings };
}
newState.uiSettings.theme = defaults.uiSettings.theme;
// Remove UI state from localStorage
localStorage.removeItem('settingsCardStates_v1');
localStorage.removeItem('dayScheduleCollapsedDays_v1');
localStorage.removeItem('medPlanAssistant_language');
localStorage.removeItem('medPlanDisclaimerAccepted_v1');
shouldRemoveMainStorage = true;
}
// If any main state category was deleted, we'll trigger a save by returning the partial state
// The useAppState hook will handle saving to localStorage
return newState; return newState;
}; };

View File

@@ -108,8 +108,7 @@ export const calculateSingleDoseConcentration = (
// Per-dose food effect takes precedence over global setting // Per-dose food effect takes precedence over global setting
const foodEnabled = isFed !== undefined ? isFed : pkParams.advanced.foodEffect.enabled; const foodEnabled = isFed !== undefined ? isFed : pkParams.advanced.foodEffect.enabled;
const tmaxDelay = foodEnabled ? parseFloat(pkParams.advanced.foodEffect.tmaxDelay) : 0; const tmaxDelay = foodEnabled ? parseFloat(pkParams.advanced.foodEffect.tmaxDelay) : 0;
const urinePHEnabled = pkParams.advanced.urinePh.enabled; const urinePHMode = pkParams.advanced.urinePh.mode;
const phTendency = urinePHEnabled ? parseFloat(pkParams.advanced.urinePh.phTendency) : 6.0;
// Validate base parameters // Validate base parameters
if (isNaN(absorptionHalfLife) || absorptionHalfLife <= 0 || if (isNaN(absorptionHalfLife) || absorptionHalfLife <= 0 ||
@@ -125,20 +124,18 @@ export const calculateSingleDoseConcentration = (
const calculationTime = adjustedTime; // Use delayed time for all kinetic calculations const calculationTime = adjustedTime; // Use delayed time for all kinetic calculations
// Apply urine pH effect on elimination half-life // Apply urine pH effect on elimination half-life
// pH < 6: acidic (faster elimination, HL ~7-9h) // Acidic: pH < 6 (faster elimination, HL ~7-9h)
// pH 6-7: normal (HL ~10-12h) // Normal: pH 6-7.5 (baseline elimination, HL ~10-12h)
// pH > 7: alkaline (slower elimination, HL ~13-15h up to 34h extreme) // Alkaline: pH > 7.5 (slower elimination, HL ~13-15h up to 34h extreme)
let adjustedDamphHL = damphHalfLife; let adjustedDamphHL = damphHalfLife;
if (urinePHEnabled) { if (urinePHMode === 'acidic') {
if (phTendency < 6.0) {
// Acidic: reduce HL by ~30% // Acidic: reduce HL by ~30%
adjustedDamphHL = damphHalfLife * 0.7; adjustedDamphHL = damphHalfLife * 0.7;
} else if (phTendency > 7.5) { } else if (urinePHMode === 'alkaline') {
// Alkaline: increase HL by ~30-40% // Alkaline: increase HL by ~30-40%
adjustedDamphHL = damphHalfLife * 1.35; adjustedDamphHL = damphHalfLife * 1.35;
} }
// else: normal pH 6-7.5, no adjustment // else: normal mode, no adjustment
}
// Calculate rate constants // Calculate rate constants
const ka_ldx = Math.log(2) / absorptionHalfLife; const ka_ldx = Math.log(2) / absorptionHalfLife;
@@ -189,13 +186,13 @@ export const calculateSingleDoseConcentration = (
} }
} }
// Weight-based Vd scaling (OVERRIDES preset if enabled) // Weight-based Vd scaling (selected as 'weight-based' preset)
// Research Section 8.1: Vd_damph ≈ 5.4 L/kg body weight // Research Section 8.1: Vd_damph ≈ 5.4 L/kg body weight
// Lighter person → smaller Vd → higher concentration // Lighter person → smaller Vd → higher concentration
// Heavier person → larger Vd → lower concentration // Heavier person → larger Vd → lower concentration
let effectiveVd_damph = baseVd_damph; let effectiveVd_damph = baseVd_damph;
if (pkParams.advanced.weightBasedVd.enabled) { if (pkParams.advanced.standardVd && pkParams.advanced.standardVd.preset === 'weight-based') {
const bodyWeight = parseFloat(pkParams.advanced.weightBasedVd.bodyWeight); const bodyWeight = parseFloat(pkParams.advanced.standardVd.bodyWeight);
if (!isNaN(bodyWeight) && bodyWeight > 0) { if (!isNaN(bodyWeight) && bodyWeight > 0) {
effectiveVd_damph = bodyWeight * 5.4; // L/kg factor from literature effectiveVd_damph = bodyWeight * 5.4; // L/kg factor from literature
} }