Compare commits
35 Commits
e7d64fb8ab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f64372b94 | |||
| 89c26fb20c | |||
| 48b2ead287 | |||
| a41189bff2 | |||
| a5bb698250 | |||
| 2cd001644e | |||
| fbba3d6122 | |||
| a1298d64a7 | |||
| 955d3ad650 | |||
| cafc0a266d | |||
| b198164760 | |||
| 3b4db14424 | |||
| d544c7f3b3 | |||
| 8325f10b19 | |||
| 7a2a8b0b47 | |||
| c41db99cba | |||
| 7f8503387c | |||
| 651097b3fb | |||
| ed79247223 | |||
| c5502085e8 | |||
| 765f7d6d35 | |||
| f76cb81108 | |||
| 383fd928d1 | |||
| 199872d742 | |||
| b7a585a223 | |||
| efa45ab288 | |||
| 11dacb5441 | |||
| 2c55652f92 | |||
| f4260061f5 | |||
| 02b1209c2d | |||
| 90b0806cec | |||
| 8e74fe576f | |||
| 3e3ca3621c | |||
| b67bfa7687 | |||
| b9a2489225 |
File diff suppressed because one or more lines are too long
129
docs/README.CONTENT_FORMATTING.md
Normal file
129
docs/README.CONTENT_FORMATTING.md
Normal 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
|
||||||
161
docs/README.CSS_UTILITY_CLASSES.md
Normal file
161
docs/README.CSS_UTILITY_CLASSES.md
Normal 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`
|
||||||
164
src/App.tsx
164
src/App.tsx
@@ -17,16 +17,22 @@ import DaySchedule from './components/day-schedule';
|
|||||||
import SimulationChart from './components/simulation-chart';
|
import SimulationChart from './components/simulation-chart';
|
||||||
import Settings from './components/settings';
|
import Settings from './components/settings';
|
||||||
import LanguageSelector from './components/language-selector';
|
import LanguageSelector from './components/language-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 { 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 = () => {
|
||||||
@@ -35,6 +41,9 @@ const MedPlanAssistant = () => {
|
|||||||
// Disclaimer modal state
|
// Disclaimer modal state
|
||||||
const [showDisclaimer, setShowDisclaimer] = React.useState(false);
|
const [showDisclaimer, setShowDisclaimer] = React.useState(false);
|
||||||
|
|
||||||
|
// Data management modal state
|
||||||
|
const [showDataManagement, setShowDataManagement] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const hasAccepted = localStorage.getItem('medPlanDisclaimerAccepted_v1');
|
const hasAccepted = localStorage.getItem('medPlanDisclaimerAccepted_v1');
|
||||||
if (!hasAccepted) {
|
if (!hasAccepted) {
|
||||||
@@ -52,41 +61,70 @@ 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
|
||||||
} = appState;
|
} = appState;
|
||||||
|
|
||||||
|
// Apply theme based on user preference or system setting
|
||||||
|
React.useEffect(() => {
|
||||||
|
const theme = uiSettings.theme || 'system';
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
const applyTheme = (isDark: boolean) => {
|
||||||
|
if (isDark) {
|
||||||
|
root.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (theme === 'system') {
|
||||||
|
// Detect system preference
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
applyTheme(mediaQuery.matches);
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
const listener = (e: MediaQueryListEvent) => applyTheme(e.matches);
|
||||||
|
mediaQuery.addEventListener('change', listener);
|
||||||
|
return () => mediaQuery.removeEventListener('change', listener);
|
||||||
|
} else {
|
||||||
|
applyTheme(theme === 'dark');
|
||||||
|
}
|
||||||
|
}, [uiSettings.theme]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
showDayTimeOnXAxis,
|
showDayTimeOnXAxis,
|
||||||
chartView,
|
chartView,
|
||||||
@@ -97,39 +135,106 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto">
|
{/* Data Management Modal */}
|
||||||
|
<DataManagementModal
|
||||||
|
isOpen={showDataManagement}
|
||||||
|
onClose={() => setShowDataManagement(false)}
|
||||||
|
t={t}
|
||||||
|
pkParams={pkParams}
|
||||||
|
days={days}
|
||||||
|
profiles={profiles}
|
||||||
|
activeProfileId={activeProfileId}
|
||||||
|
therapeuticRange={therapeuticRange}
|
||||||
|
doseIncrement={doseIncrement}
|
||||||
|
uiSettings={uiSettings}
|
||||||
|
onUpdatePkParams={(key: any, value: any) => updateNestedState('pkParams', key, value)}
|
||||||
|
onUpdateTherapeuticRange={(key: any, value: any) => updateNestedState('therapeuticRange', key, value)}
|
||||||
|
onUpdateUiSetting={(key: any, value: any) => updateUiSetting(key as any, value)}
|
||||||
|
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" 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">
|
<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-reverse gap-2 justify-end">
|
||||||
|
<ThemeSelector
|
||||||
|
currentTheme={uiSettings.theme || 'system'}
|
||||||
|
onThemeChange={(theme: 'light' | 'dark' | 'system') => updateUiSetting('theme', theme)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
<LanguageSelector currentLanguage={currentLanguage} onLanguageChange={changeLanguage} t={t} />
|
<LanguageSelector currentLanguage={currentLanguage} onLanguageChange={changeLanguage} t={t} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<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">
|
||||||
@@ -189,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}
|
||||||
@@ -201,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}
|
||||||
@@ -217,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}
|
||||||
@@ -227,15 +344,16 @@ 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)}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</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>
|
||||||
|
|||||||
1172
src/components/data-management-modal.tsx
Normal file
1172
src/components/data-management-modal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -10,12 +10,9 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||||
import { Label } from './ui/label';
|
|
||||||
|
|
||||||
const LanguageSelector = ({ currentLanguage, onLanguageChange, t }: any) => {
|
const LanguageSelector = ({ currentLanguage, onLanguageChange, t }: any) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap-reverse items-center gap-2">
|
|
||||||
<Label className="text-sm font-medium">{t('languageSelectorLabel')}</Label>
|
|
||||||
<Select value={currentLanguage} onValueChange={onLanguageChange}>
|
<Select value={currentLanguage} onValueChange={onLanguageChange}>
|
||||||
<SelectTrigger className="w-32">
|
<SelectTrigger className="w-32">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -25,7 +22,6 @@ const LanguageSelector = ({ currentLanguage, onLanguageChange, t }: any) => {
|
|||||||
<SelectItem value="de">{t('languageSelectorDE')}</SelectItem>
|
<SelectItem value="de">{t('languageSelectorDE')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
319
src/components/profile-selector.tsx
Normal file
319
src/components/profile-selector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
@@ -220,14 +294,14 @@ 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 */}
|
||||||
@@ -405,55 +522,32 @@ const chartDomain = React.useMemo(() => {
|
|||||||
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;
|
||||||
|
|||||||
29
src/components/theme-selector.tsx
Normal file
29
src/components/theme-selector.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Theme Selector Component
|
||||||
|
*
|
||||||
|
* Provides UI for switching between light/dark/system theme modes.
|
||||||
|
* Uses shadcn/ui Select component.
|
||||||
|
*
|
||||||
|
* @author Andreas Weyer
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||||
|
|
||||||
|
const ThemeSelector = ({ currentTheme, onThemeChange, t }: any) => {
|
||||||
|
return (
|
||||||
|
<Select value={currentTheme} onValueChange={onThemeChange}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="light">{t('themeSelectorLight')}</SelectItem>
|
||||||
|
<SelectItem value="dark">{t('themeSelectorDark')}</SelectItem>
|
||||||
|
<SelectItem value="system">{t('themeSelectorSystem')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeSelector;
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
65
src/components/ui/form-select.tsx
Normal file
65
src/components/ui/form-select.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
21
src/components/ui/textarea.tsx
Normal file
21
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -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,14 +107,18 @@ 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;
|
||||||
|
theme?: 'light' | 'dark' | 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -129,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: {
|
||||||
@@ -137,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',
|
||||||
@@ -169,7 +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',
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
33
src/hooks/useDebounce.ts
Normal 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;
|
||||||
|
}
|
||||||
71
src/hooks/useElementSize.ts
Normal file
71
src/hooks/useElementSize.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}))
|
}))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
46
src/hooks/useWindowSize.ts
Normal file
46
src/hooks/useWindowSize.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -10,15 +10,20 @@ 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",
|
||||||
languageSelectorEN: "English",
|
languageSelectorEN: "English",
|
||||||
languageSelectorDE: "Deutsch",
|
languageSelectorDE: "Deutsch",
|
||||||
|
|
||||||
|
// Theme selector
|
||||||
|
themeSelectorLight: "☀️ Hell",
|
||||||
|
themeSelectorDark: "🌙 Dunkel",
|
||||||
|
themeSelectorSystem: "💻 System",
|
||||||
|
|
||||||
// Dose Schedule
|
// Dose Schedule
|
||||||
myPlan: "Mein Plan",
|
myPlan: "Mein Zeitplan",
|
||||||
morning: "Morgens",
|
morning: "Morgens",
|
||||||
midday: "Mittags",
|
midday: "Mittags",
|
||||||
afternoon: "Nachmittags",
|
afternoon: "Nachmittags",
|
||||||
@@ -27,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",
|
||||||
@@ -48,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}}",
|
||||||
@@ -62,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",
|
||||||
@@ -74,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...)",
|
||||||
@@ -86,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)",
|
||||||
@@ -160,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",
|
||||||
@@ -201,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",
|
||||||
@@ -218,12 +256,51 @@ export const de = {
|
|||||||
importFileNotSelected: "Keine Datei ausgewählt",
|
importFileNotSelected: "Keine Datei ausgewählt",
|
||||||
exportImportTooltip: "Exportiere deine Einstellungen als Backup oder zum Teilen. Importiere zuvor exportierte Einstellungen. Wähle individuell, welche Teile exportiert/importiert werden sollen.",
|
exportImportTooltip: "Exportiere deine Einstellungen als Backup oder zum Teilen. Importiere zuvor exportierte Einstellungen. Wähle individuell, welche Teile exportiert/importiert werden sollen.",
|
||||||
|
|
||||||
|
// Data Management Modal
|
||||||
|
dataManagementTitle: "Datenverwaltung",
|
||||||
|
dataManagementSubtitle: "Exportieren, importieren und verwalten Sie Ihre Anwendungsdaten",
|
||||||
|
openDataManagement: "Daten verwalten...",
|
||||||
|
copyToClipboard: "In Zwischenablage kopieren",
|
||||||
|
pasteFromClipboard: "Aus Zwischenablage einfügen",
|
||||||
|
exportActions: "Export-Aktionen",
|
||||||
|
importActions: "Import-Aktionen",
|
||||||
|
showJsonEditor: "JSON-Editor anzeigen",
|
||||||
|
hideJsonEditor: "JSON-Editor ausblenden",
|
||||||
|
jsonEditorLabel: "JSON-Editor",
|
||||||
|
jsonEditorPlaceholder: "Fügen Sie hier Ihr JSON-Backup ein oder bearbeiten Sie die exportierten Daten...",
|
||||||
|
jsonEditorTooltip: "Bearbeiten Sie exportierte Daten direkt oder fügen Sie Backup-JSON ein. Manuelle Bearbeitung erfordert JSON-Kenntnisse.",
|
||||||
|
copiedToClipboard: "In Zwischenablage kopiert!",
|
||||||
|
copyFailed: "Kopieren in Zwischenablage fehlgeschlagen",
|
||||||
|
pasteSuccess: "JSON erfolgreich eingefügt",
|
||||||
|
pasteFailed: "Einfügen aus Zwischenablage fehlgeschlagen",
|
||||||
|
pasteNoClipboardApi: "Zwischenablage-Zugriff nicht verfügbar. Bitte manuell einfügen.",
|
||||||
|
pasteInvalidJson: "Ungültiges JSON-Format. Bitte überprüfen Sie Ihre Daten.",
|
||||||
|
jsonEditWarning: "⚠️ Manuelle Bearbeitung erfordert JSON-Kenntnisse. Ungültige Daten können Fehler verursachen.",
|
||||||
|
validateJson: "JSON validieren",
|
||||||
|
clearJson: "Löschen",
|
||||||
|
jsonValidationSuccess: "JSON ist gültig",
|
||||||
|
jsonValidationError: "✗ Ungültiges JSON",
|
||||||
|
closeDataManagement: "Schließen",
|
||||||
|
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.",
|
||||||
@@ -233,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
|
||||||
@@ -242,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",
|
||||||
@@ -258,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",
|
||||||
|
|||||||
@@ -10,15 +10,20 @@ 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",
|
||||||
languageSelectorEN: "English",
|
languageSelectorEN: "English",
|
||||||
languageSelectorDE: "Deutsch",
|
languageSelectorDE: "Deutsch",
|
||||||
|
|
||||||
|
// Theme selector
|
||||||
|
themeSelectorLight: "☀️ Light",
|
||||||
|
themeSelectorDark: "🌙 Dark",
|
||||||
|
themeSelectorSystem: "💻 System",
|
||||||
|
|
||||||
// Dose Schedule
|
// Dose Schedule
|
||||||
myPlan: "My Plan",
|
myPlan: "My Schedule",
|
||||||
morning: "Morning",
|
morning: "Morning",
|
||||||
midday: "Midday",
|
midday: "Midday",
|
||||||
afternoon: "Afternoon",
|
afternoon: "Afternoon",
|
||||||
@@ -27,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?",
|
||||||
@@ -48,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}}",
|
||||||
@@ -62,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",
|
||||||
@@ -73,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...)",
|
||||||
@@ -85,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)",
|
||||||
@@ -158,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",
|
||||||
@@ -194,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",
|
||||||
@@ -216,12 +254,51 @@ export const en = {
|
|||||||
importFileNotSelected: "No file selected",
|
importFileNotSelected: "No file selected",
|
||||||
exportImportTooltip: "Export your settings as backup or share with others. Import previously exported settings. Choose which parts to export/import individually.",
|
exportImportTooltip: "Export your settings as backup or share with others. Import previously exported settings. Choose which parts to export/import individually.",
|
||||||
|
|
||||||
|
// Data Management Modal
|
||||||
|
dataManagementTitle: "Data Management",
|
||||||
|
dataManagementSubtitle: "Export, import, and manage your application data",
|
||||||
|
openDataManagement: "Manage Data...",
|
||||||
|
copyToClipboard: "Copy to Clipboard",
|
||||||
|
pasteFromClipboard: "Paste from Clipboard",
|
||||||
|
exportActions: "Export Actions",
|
||||||
|
importActions: "Import Actions",
|
||||||
|
showJsonEditor: "Show JSON Editor",
|
||||||
|
hideJsonEditor: "Hide JSON Editor",
|
||||||
|
jsonEditorLabel: "JSON Editor",
|
||||||
|
jsonEditorPlaceholder: "Paste your JSON backup here or edit the exported data...",
|
||||||
|
jsonEditorTooltip: "Edit exported data directly or paste backup JSON. Manual editing requires JSON knowledge.",
|
||||||
|
copiedToClipboard: "Copied to clipboard!",
|
||||||
|
copyFailed: "Failed to copy to clipboard",
|
||||||
|
pasteSuccess: "JSON pasted successfully",
|
||||||
|
pasteFailed: "Failed to paste from clipboard",
|
||||||
|
pasteNoClipboardApi: "Clipboard access not available. Please paste manually.",
|
||||||
|
pasteInvalidJson: "Invalid JSON format. Please check your data.",
|
||||||
|
jsonEditWarning: "⚠️ Manual editing requires JSON knowledge. Invalid data may cause errors.",
|
||||||
|
validateJson: "Validate JSON",
|
||||||
|
clearJson: "Clear",
|
||||||
|
jsonValidationSuccess: "JSON is valid",
|
||||||
|
jsonValidationError: "✗ Invalid JSON",
|
||||||
|
closeDataManagement: "Close",
|
||||||
|
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.",
|
||||||
@@ -232,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",
|
||||||
@@ -254,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",
|
||||||
@@ -268,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;
|
||||||
|
|||||||
@@ -31,6 +31,37 @@
|
|||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 0 0% 10%;
|
||||||
|
--foreground: 0 0% 95%;
|
||||||
|
--card: 0 0% 14%;
|
||||||
|
--card-foreground: 0 0% 95%;
|
||||||
|
--popover: 0 0% 12%;
|
||||||
|
--popover-foreground: 0 0% 95%;
|
||||||
|
--primary: 217 91% 60%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 220 15% 20%;
|
||||||
|
--secondary-foreground: 0 0% 90%;
|
||||||
|
--muted: 220 10% 18%;
|
||||||
|
--muted-foreground: 0 0% 60%;
|
||||||
|
--accent: 220 10% 18%;
|
||||||
|
--accent-foreground: 0 0% 90%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--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%;
|
||||||
|
--input: 0 0% 25%;
|
||||||
|
--ring: 0 0% 40%;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
border-color: hsl(var(--border));
|
border-color: hsl(var(--border));
|
||||||
}
|
}
|
||||||
@@ -41,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
230
src/utils/contentFormatter.tsx
Normal file
230
src/utils/contentFormatter.tsx
Normal 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
|
||||||
@@ -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 = ['weightBasedVd', 'foodEffect', 'urinePh', 'fOral', 'steadyStateDays'];
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user