Compare commits
18 Commits
ed79247223
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f64372b94 | |||
| 89c26fb20c | |||
| 48b2ead287 | |||
| a41189bff2 | |||
| a5bb698250 | |||
| 2cd001644e | |||
| fbba3d6122 | |||
| a1298d64a7 | |||
| 955d3ad650 | |||
| cafc0a266d | |||
| b198164760 | |||
| 3b4db14424 | |||
| d544c7f3b3 | |||
| 8325f10b19 | |||
| 7a2a8b0b47 | |||
| c41db99cba | |||
| 7f8503387c | |||
| 651097b3fb |
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`
|
||||||
73
src/App.tsx
73
src/App.tsx
@@ -20,6 +20,7 @@ import LanguageSelector from './components/language-selector';
|
|||||||
import ThemeSelector from './components/theme-selector';
|
import ThemeSelector from './components/theme-selector';
|
||||||
import DisclaimerModal from './components/disclaimer-modal';
|
import DisclaimerModal from './components/disclaimer-modal';
|
||||||
import DataManagementModal from './components/data-management-modal';
|
import DataManagementModal from './components/data-management-modal';
|
||||||
|
import { ProfileSelector } from './components/profile-selector';
|
||||||
import { Button } from './components/ui/button';
|
import { Button } from './components/ui/button';
|
||||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from './components/ui/tooltip';
|
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from './components/ui/tooltip';
|
||||||
import { IconButtonWithTooltip } from './components/ui/icon-button-with-tooltip';
|
import { IconButtonWithTooltip } from './components/ui/icon-button-with-tooltip';
|
||||||
@@ -31,6 +32,7 @@ import type { ExportOptions } from './utils/exportImport';
|
|||||||
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 = () => {
|
||||||
@@ -59,17 +61,9 @@ 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,
|
||||||
@@ -82,12 +76,23 @@ const MedPlanAssistant = () => {
|
|||||||
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
|
||||||
@@ -130,6 +135,7 @@ const MedPlanAssistant = () => {
|
|||||||
displayedDays,
|
displayedDays,
|
||||||
showDayReferenceLines
|
showDayReferenceLines
|
||||||
} = uiSettings;
|
} = uiSettings;
|
||||||
|
const showIntakeTimeLines = (uiSettings as any).showIntakeTimeLines ?? false;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
combinedProfile,
|
combinedProfile,
|
||||||
@@ -144,6 +150,10 @@ const MedPlanAssistant = () => {
|
|||||||
Object.entries(newState).forEach(([key, value]) => {
|
Object.entries(newState).forEach(([key, value]) => {
|
||||||
if (key === 'days') {
|
if (key === 'days') {
|
||||||
updateState('days', value as any);
|
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') {
|
} else if (key === 'pkParams') {
|
||||||
updateState('pkParams', value as any);
|
updateState('pkParams', value as any);
|
||||||
} else if (key === 'therapeuticRange') {
|
} else if (key === 'therapeuticRange') {
|
||||||
@@ -161,7 +171,7 @@ const MedPlanAssistant = () => {
|
|||||||
|
|
||||||
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}
|
||||||
@@ -180,6 +190,8 @@ const MedPlanAssistant = () => {
|
|||||||
t={t}
|
t={t}
|
||||||
pkParams={pkParams}
|
pkParams={pkParams}
|
||||||
days={days}
|
days={days}
|
||||||
|
profiles={profiles}
|
||||||
|
activeProfileId={activeProfileId}
|
||||||
therapeuticRange={therapeuticRange}
|
therapeuticRange={therapeuticRange}
|
||||||
doseIncrement={doseIncrement}
|
doseIncrement={doseIncrement}
|
||||||
uiSettings={uiSettings}
|
uiSettings={uiSettings}
|
||||||
@@ -187,12 +199,20 @@ const MedPlanAssistant = () => {
|
|||||||
onUpdateTherapeuticRange={(key: any, value: any) => updateNestedState('therapeuticRange', key, value)}
|
onUpdateTherapeuticRange={(key: any, value: any) => updateNestedState('therapeuticRange', key, value)}
|
||||||
onUpdateUiSetting={(key: any, value: any) => updateUiSetting(key as any, value)}
|
onUpdateUiSetting={(key: any, value: any) => updateUiSetting(key as any, value)}
|
||||||
onImportDays={(importedDays: any) => updateState('days', importedDays)}
|
onImportDays={(importedDays: any) => updateState('days', importedDays)}
|
||||||
|
onImportProfiles={(importedProfiles: any, newActiveProfileId: string) => {
|
||||||
|
updateState('profiles', importedProfiles);
|
||||||
|
updateState('activeProfileId', newActiveProfileId);
|
||||||
|
const newActiveProfile = importedProfiles.find((p: any) => p.id === newActiveProfileId);
|
||||||
|
if (newActiveProfile) {
|
||||||
|
updateState('days', newActiveProfile.days);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onDeleteData={handleDeleteData}
|
onDeleteData={handleDeleteData}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto" style={{
|
<div className="max-w-7xl mx-auto" style={{
|
||||||
// TODO ideally we would have a value around 320px or similar for mobile devices but this causes layout issues (consider e.g. wrapping) and makes the chart hard to read
|
// TODO solution not ideal for mobile, consider https://tailwindcss.com/docs/responsive-design
|
||||||
minWidth: '410px' //minWidth: '320px'
|
minWidth: '480px'
|
||||||
}}>
|
}}>
|
||||||
<header className="mb-8">
|
<header className="mb-8">
|
||||||
<div className="flex justify-between items-start gap-4">
|
<div className="flex justify-between items-start gap-4">
|
||||||
@@ -211,10 +231,10 @@ const MedPlanAssistant = () => {
|
|||||||
<p className="text-muted-foreground mt-1">{t('appSubtitle')}</p>
|
<p className="text-muted-foreground mt-1">{t('appSubtitle')}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
|
||||||
{/* Both Columns - Chart */}
|
{/* Both Columns - Chart */}
|
||||||
<div className={`xl:col-span-2 bg-card p-6 rounded-lg border min-h-[600px] flex flex-col ${uiSettings.stickyChart ? 'sticky top-2 z-30 shadow-lg' : ''}`}
|
<div className={`lg:col-span-2 bg-card p-6 rounded-lg border min-h-[600px] flex flex-col ${uiSettings.stickyChart ? 'sticky top-2 z-30 shadow-lg' : ''}`}
|
||||||
style={uiSettings.stickyChart ? { borderColor: 'hsl(var(--primary))' } : {}}>
|
style={uiSettings.stickyChart ? { borderColor: 'hsl(var(--primary))' } : {}}>
|
||||||
<div className="flex flex-wrap items-center gap-3 justify-between mb-4">
|
<div className="flex flex-wrap items-center gap-3 justify-between mb-4">
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
@@ -274,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}
|
||||||
@@ -286,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}
|
||||||
@@ -302,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}
|
||||||
@@ -320,7 +352,8 @@ const MedPlanAssistant = () => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="mt-8 p-4 bg-muted rounded-lg text-sm text-muted-foreground border">
|
{/* Footer */}
|
||||||
|
<footer className="mt-8 p-4 bg-muted rounded-lg text-sm border">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-2 text-foreground">{t('importantNote')}</h3>
|
<h3 className="font-semibold mb-2 text-foreground">{t('importantNote')}</h3>
|
||||||
|
|||||||
@@ -44,11 +44,15 @@ import {
|
|||||||
parseImportFile,
|
parseImportFile,
|
||||||
validateImportData,
|
validateImportData,
|
||||||
importSettings,
|
importSettings,
|
||||||
|
type ImportOptions,
|
||||||
} from '../utils/exportImport';
|
} from '../utils/exportImport';
|
||||||
import { APP_VERSION } from '../constants/defaults';
|
import { formatContent } from '../utils/contentFormatter';
|
||||||
|
import { APP_VERSION, MAX_PROFILES } from '../constants/defaults';
|
||||||
|
|
||||||
interface ExportImportOptions {
|
interface ExportImportOptions {
|
||||||
includeSchedules: boolean;
|
includeSchedules: boolean;
|
||||||
|
exportAllProfiles?: boolean;
|
||||||
|
restoreExamples?: boolean;
|
||||||
includeDiagramSettings: boolean;
|
includeDiagramSettings: boolean;
|
||||||
includeSimulationSettings: boolean;
|
includeSimulationSettings: boolean;
|
||||||
includePharmacoSettings: boolean;
|
includePharmacoSettings: boolean;
|
||||||
@@ -63,6 +67,8 @@ interface DataManagementModalProps {
|
|||||||
// App state
|
// App state
|
||||||
pkParams: any;
|
pkParams: any;
|
||||||
days: any;
|
days: any;
|
||||||
|
profiles?: any[];
|
||||||
|
activeProfileId?: string;
|
||||||
therapeuticRange: any;
|
therapeuticRange: any;
|
||||||
doseIncrement: any;
|
doseIncrement: any;
|
||||||
uiSettings: any;
|
uiSettings: any;
|
||||||
@@ -71,6 +77,7 @@ interface DataManagementModalProps {
|
|||||||
onUpdateTherapeuticRange: (key: string, value: any) => void;
|
onUpdateTherapeuticRange: (key: string, value: any) => void;
|
||||||
onUpdateUiSetting: (key: string, value: any) => void;
|
onUpdateUiSetting: (key: string, value: any) => void;
|
||||||
onImportDays?: (days: any) => void;
|
onImportDays?: (days: any) => void;
|
||||||
|
onImportProfiles?: (profiles: any[], activeProfileId: string) => void;
|
||||||
onDeleteData?: (options: ExportImportOptions) => void;
|
onDeleteData?: (options: ExportImportOptions) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +87,8 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
t,
|
t,
|
||||||
pkParams,
|
pkParams,
|
||||||
days,
|
days,
|
||||||
|
profiles,
|
||||||
|
activeProfileId,
|
||||||
therapeuticRange,
|
therapeuticRange,
|
||||||
doseIncrement,
|
doseIncrement,
|
||||||
uiSettings,
|
uiSettings,
|
||||||
@@ -87,11 +96,13 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
onUpdateTherapeuticRange,
|
onUpdateTherapeuticRange,
|
||||||
onUpdateUiSetting,
|
onUpdateUiSetting,
|
||||||
onImportDays,
|
onImportDays,
|
||||||
|
onImportProfiles,
|
||||||
onDeleteData,
|
onDeleteData,
|
||||||
}) => {
|
}) => {
|
||||||
// Export/Import options
|
// Export/Import options
|
||||||
const [exportOptions, setExportOptions] = useState<ExportImportOptions>({
|
const [exportOptions, setExportOptions] = useState<ExportImportOptions>({
|
||||||
includeSchedules: true,
|
includeSchedules: true,
|
||||||
|
exportAllProfiles: true, // Default to exporting all profiles
|
||||||
includeDiagramSettings: true,
|
includeDiagramSettings: true,
|
||||||
includeSimulationSettings: true,
|
includeSimulationSettings: true,
|
||||||
includePharmacoSettings: true,
|
includePharmacoSettings: true,
|
||||||
@@ -108,14 +119,17 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
includeOtherData: false,
|
includeOtherData: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [mergeProfiles, setMergeProfiles] = useState(false);
|
||||||
|
|
||||||
// Deletion options - defaults: all except otherData
|
// Deletion options - defaults: all except otherData
|
||||||
const [deletionOptions, setDeletionOptions] = useState<ExportImportOptions>({
|
const [deletionOptions, setDeletionOptions] = useState<ExportImportOptions>({
|
||||||
includeSchedules: true,
|
includeSchedules: false,
|
||||||
includeDiagramSettings: true,
|
restoreExamples: true, // Restore examples by default
|
||||||
includeSimulationSettings: true,
|
includeDiagramSettings: false,
|
||||||
includePharmacoSettings: true,
|
includeSimulationSettings: false,
|
||||||
includeAdvancedSettings: true,
|
includePharmacoSettings: false,
|
||||||
includeOtherData: true,
|
includeAdvancedSettings: false,
|
||||||
|
includeOtherData: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// File upload state
|
// File upload state
|
||||||
@@ -134,6 +148,16 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
// Clipboard feedback
|
// Clipboard feedback
|
||||||
const [copySuccess, setCopySuccess] = useState(false);
|
const [copySuccess, setCopySuccess] = useState(false);
|
||||||
|
|
||||||
|
// Track which categories are available in the loaded JSON
|
||||||
|
const [availableCategories, setAvailableCategories] = useState<{
|
||||||
|
schedules: boolean;
|
||||||
|
diagramSettings: boolean;
|
||||||
|
simulationSettings: boolean;
|
||||||
|
pharmacoSettings: boolean;
|
||||||
|
advancedSettings: boolean;
|
||||||
|
otherData: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Reset editor when modal opens/closes
|
// Reset editor when modal opens/closes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// TODO nice to have: use can decide behavior via checkbox (near editor)
|
// TODO nice to have: use can decide behavior via checkbox (near editor)
|
||||||
@@ -158,6 +182,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
setJsonEditorContent('');
|
setJsonEditorContent('');
|
||||||
setJsonEditorExpanded(false);
|
setJsonEditorExpanded(false);
|
||||||
setJsonValidationMessage({ type: null, message: '' });
|
setJsonValidationMessage({ type: null, message: '' });
|
||||||
|
setAvailableCategories(null);
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = '';
|
fileInputRef.current.value = '';
|
||||||
@@ -177,6 +202,8 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
const appState = {
|
const appState = {
|
||||||
pkParams,
|
pkParams,
|
||||||
days,
|
days,
|
||||||
|
profiles: profiles || [],
|
||||||
|
activeProfileId: activeProfileId || '',
|
||||||
therapeuticRange,
|
therapeuticRange,
|
||||||
doseIncrement,
|
doseIncrement,
|
||||||
uiSettings,
|
uiSettings,
|
||||||
@@ -197,6 +224,8 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
const appState = {
|
const appState = {
|
||||||
pkParams,
|
pkParams,
|
||||||
days,
|
days,
|
||||||
|
profiles: profiles || [],
|
||||||
|
activeProfileId: activeProfileId || '',
|
||||||
therapeuticRange,
|
therapeuticRange,
|
||||||
doseIncrement,
|
doseIncrement,
|
||||||
uiSettings,
|
uiSettings,
|
||||||
@@ -292,6 +321,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
const validateJsonContent = (content: string) => {
|
const validateJsonContent = (content: string) => {
|
||||||
if (!content.trim()) {
|
if (!content.trim()) {
|
||||||
setJsonValidationMessage({ type: null, message: '' });
|
setJsonValidationMessage({ type: null, message: '' });
|
||||||
|
setAvailableCategories(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,6 +334,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
type: 'error',
|
type: 'error',
|
||||||
message: t('importParseError'),
|
message: t('importParseError'),
|
||||||
});
|
});
|
||||||
|
setAvailableCategories(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,9 +345,21 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
type: 'error',
|
type: 'error',
|
||||||
message: validation.errors.join(', '),
|
message: validation.errors.join(', '),
|
||||||
});
|
});
|
||||||
|
setAvailableCategories(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect which categories are present in the JSON
|
||||||
|
const categories = {
|
||||||
|
schedules: !!(importData.data.profiles || importData.data.schedules),
|
||||||
|
diagramSettings: !!importData.data.diagramSettings,
|
||||||
|
simulationSettings: !!importData.data.simulationSettings,
|
||||||
|
pharmacoSettings: !!importData.data.pharmacoSettings,
|
||||||
|
advancedSettings: !!importData.data.advancedSettings,
|
||||||
|
otherData: !!importData.data.otherData,
|
||||||
|
};
|
||||||
|
setAvailableCategories(categories);
|
||||||
|
|
||||||
if (validation.warnings.length > 0) {
|
if (validation.warnings.length > 0) {
|
||||||
// Show success with warnings - warnings will be displayed separately
|
// Show success with warnings - warnings will be displayed separately
|
||||||
setJsonValidationMessage({
|
setJsonValidationMessage({
|
||||||
@@ -336,6 +379,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
type: 'error',
|
type: 'error',
|
||||||
message: t('pasteInvalidJson'),
|
message: t('pasteInvalidJson'),
|
||||||
});
|
});
|
||||||
|
setAvailableCategories(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -400,15 +444,26 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
const currentState = {
|
const currentState = {
|
||||||
pkParams,
|
pkParams,
|
||||||
days,
|
days,
|
||||||
|
profiles: profiles || [],
|
||||||
|
activeProfileId: activeProfileId || '',
|
||||||
therapeuticRange,
|
therapeuticRange,
|
||||||
doseIncrement,
|
doseIncrement,
|
||||||
uiSettings,
|
uiSettings,
|
||||||
steadyStateConfig: { daysOnMedication: pkParams.advanced.steadyStateDays },
|
steadyStateConfig: { daysOnMedication: pkParams.advanced.steadyStateDays },
|
||||||
};
|
};
|
||||||
const newState = importSettings(currentState, importData.data, importOptions);
|
|
||||||
|
|
||||||
// Apply schedules
|
const importOpts: ImportOptions = {
|
||||||
if (newState.days && importOptions.includeSchedules && onImportDays) {
|
mergeProfiles: mergeProfiles
|
||||||
|
};
|
||||||
|
|
||||||
|
const newState = importSettings(currentState, importData.data, importOptions, importOpts);
|
||||||
|
|
||||||
|
// Apply profiles (new approach)
|
||||||
|
if (newState.profiles && newState.activeProfileId && importOptions.includeSchedules && onImportProfiles) {
|
||||||
|
onImportProfiles(newState.profiles, newState.activeProfileId);
|
||||||
|
}
|
||||||
|
// Fallback: Apply schedules (legacy)
|
||||||
|
else if (newState.days && importOptions.includeSchedules && onImportDays) {
|
||||||
onImportDays(newState.days);
|
onImportDays(newState.days);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,8 +502,12 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Import error:', error);
|
console.error('Import error:', error);
|
||||||
|
if (error instanceof Error && error.message.includes('exceed maximum')) {
|
||||||
|
alert(error.message);
|
||||||
|
} else {
|
||||||
alert(t('importError'));
|
alert(t('importError'));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clear JSON editor
|
// Clear JSON editor
|
||||||
@@ -463,7 +522,15 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
|
|
||||||
// Handle delete selected data
|
// Handle delete selected data
|
||||||
const handleDeleteData = () => {
|
const handleDeleteData = () => {
|
||||||
const hasAnySelected = Object.values(deletionOptions).some(v => v);
|
// Check if any actual deletion categories are selected (excluding restoreExamples which is just an option)
|
||||||
|
const hasAnySelected =
|
||||||
|
deletionOptions.includeSchedules ||
|
||||||
|
deletionOptions.includeDiagramSettings ||
|
||||||
|
deletionOptions.includeSimulationSettings ||
|
||||||
|
deletionOptions.includePharmacoSettings ||
|
||||||
|
deletionOptions.includeAdvancedSettings ||
|
||||||
|
deletionOptions.includeOtherData;
|
||||||
|
|
||||||
if (!hasAnySelected) {
|
if (!hasAnySelected) {
|
||||||
alert(t('deleteNoOptionsSelected'));
|
alert(t('deleteNoOptionsSelected'));
|
||||||
return;
|
return;
|
||||||
@@ -544,6 +611,33 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
{t('exportOptionSchedules')}
|
{t('exportOptionSchedules')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
{exportOptions.includeSchedules && profiles && profiles.length > 1 && (
|
||||||
|
<div className="flex items-center gap-3 pl-8">
|
||||||
|
<Switch
|
||||||
|
id="export-all-profiles"
|
||||||
|
checked={exportOptions.exportAllProfiles ?? true}
|
||||||
|
onCheckedChange={checked =>
|
||||||
|
setExportOptions({ ...exportOptions, exportAllProfiles: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="export-all-profiles" className="text-sm text-muted-foreground">
|
||||||
|
{t('exportAllProfiles')} ({profiles.length} {t('profiles')})
|
||||||
|
</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="text-xs max-w-xs">{formatContent(t('exportAllProfilesTooltip'))}</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Switch
|
<Switch
|
||||||
id="export-diagram"
|
id="export-diagram"
|
||||||
@@ -666,6 +760,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
<Switch
|
<Switch
|
||||||
id="import-schedules"
|
id="import-schedules"
|
||||||
checked={importOptions.includeSchedules}
|
checked={importOptions.includeSchedules}
|
||||||
|
disabled={availableCategories !== null && !availableCategories.schedules}
|
||||||
onCheckedChange={checked =>
|
onCheckedChange={checked =>
|
||||||
setImportOptions({ ...importOptions, includeSchedules: checked })
|
setImportOptions({ ...importOptions, includeSchedules: checked })
|
||||||
}
|
}
|
||||||
@@ -674,10 +769,36 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
{t('exportOptionSchedules')}
|
{t('exportOptionSchedules')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
{importOptions.includeSchedules && profiles && (
|
||||||
|
<div className="flex items-center gap-3 pl-8">
|
||||||
|
<Switch
|
||||||
|
id="merge-profiles"
|
||||||
|
checked={mergeProfiles}
|
||||||
|
onCheckedChange={setMergeProfiles}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="merge-profiles" className="text-sm text-muted-foreground">
|
||||||
|
{t('mergeProfiles')}
|
||||||
|
</Label>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<div className="text-xs max-w-xs">{formatContent(t('mergeProfilesTooltip'))}</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Switch
|
<Switch
|
||||||
id="import-diagram"
|
id="import-diagram"
|
||||||
checked={importOptions.includeDiagramSettings}
|
checked={importOptions.includeDiagramSettings}
|
||||||
|
disabled={availableCategories !== null && !availableCategories.diagramSettings}
|
||||||
onCheckedChange={checked =>
|
onCheckedChange={checked =>
|
||||||
setImportOptions({ ...importOptions, includeDiagramSettings: checked })
|
setImportOptions({ ...importOptions, includeDiagramSettings: checked })
|
||||||
}
|
}
|
||||||
@@ -690,6 +811,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
<Switch
|
<Switch
|
||||||
id="import-simulation"
|
id="import-simulation"
|
||||||
checked={importOptions.includeSimulationSettings}
|
checked={importOptions.includeSimulationSettings}
|
||||||
|
disabled={availableCategories !== null && !availableCategories.simulationSettings}
|
||||||
onCheckedChange={checked =>
|
onCheckedChange={checked =>
|
||||||
setImportOptions({ ...importOptions, includeSimulationSettings: checked })
|
setImportOptions({ ...importOptions, includeSimulationSettings: checked })
|
||||||
}
|
}
|
||||||
@@ -702,6 +824,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
<Switch
|
<Switch
|
||||||
id="import-pharmaco"
|
id="import-pharmaco"
|
||||||
checked={importOptions.includePharmacoSettings}
|
checked={importOptions.includePharmacoSettings}
|
||||||
|
disabled={availableCategories !== null && !availableCategories.pharmacoSettings}
|
||||||
onCheckedChange={checked =>
|
onCheckedChange={checked =>
|
||||||
setImportOptions({ ...importOptions, includePharmacoSettings: checked })
|
setImportOptions({ ...importOptions, includePharmacoSettings: checked })
|
||||||
}
|
}
|
||||||
@@ -714,6 +837,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
<Switch
|
<Switch
|
||||||
id="import-advanced"
|
id="import-advanced"
|
||||||
checked={importOptions.includeAdvancedSettings}
|
checked={importOptions.includeAdvancedSettings}
|
||||||
|
disabled={availableCategories !== null && !availableCategories.advancedSettings}
|
||||||
onCheckedChange={checked =>
|
onCheckedChange={checked =>
|
||||||
setImportOptions({ ...importOptions, includeAdvancedSettings: checked })
|
setImportOptions({ ...importOptions, includeAdvancedSettings: checked })
|
||||||
}
|
}
|
||||||
@@ -726,6 +850,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
<Switch
|
<Switch
|
||||||
id="import-other"
|
id="import-other"
|
||||||
checked={importOptions.includeOtherData}
|
checked={importOptions.includeOtherData}
|
||||||
|
disabled={availableCategories !== null && !availableCategories.otherData}
|
||||||
onCheckedChange={checked =>
|
onCheckedChange={checked =>
|
||||||
setImportOptions({ ...importOptions, includeOtherData: checked })
|
setImportOptions({ ...importOptions, includeOtherData: checked })
|
||||||
}
|
}
|
||||||
@@ -843,7 +968,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
className={`flex items-center gap-2 text-sm ${
|
className={`flex items-center gap-2 text-sm ${
|
||||||
jsonValidationMessage.type === 'success'
|
jsonValidationMessage.type === 'success'
|
||||||
? 'text-green-600 dark:text-green-400'
|
? 'text-green-600 dark:text-green-400'
|
||||||
: 'text-red-600 dark:text-red-400'
|
: 'error-text'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{jsonValidationMessage.type === 'success' ? (
|
{jsonValidationMessage.type === 'success' ? (
|
||||||
@@ -858,7 +983,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
{jsonValidationMessage.warnings.map((warning, index) => (
|
{jsonValidationMessage.warnings.map((warning, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="bg-yellow-500 text-white text-xs p-2 rounded-md"
|
className="warning-bubble text-xs p-2 rounded-md"
|
||||||
>
|
>
|
||||||
{warning}
|
{warning}
|
||||||
</div>
|
</div>
|
||||||
@@ -909,7 +1034,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Trash2 className="h-5 w-5 text-destructive" />
|
<Trash2 className="h-5 w-5 text-destructive" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-destructive">{t('deleteSpecificData')}</h3>
|
<h3 className="text-lg font-semibold">{t('deleteSpecificData')}</h3>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -926,8 +1051,8 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Warning Message */}
|
{/* Warning Message */}
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 text-sm">
|
<div className="warning-bg-box rounded-md p-3 text-sm">
|
||||||
<p className="text-yellow-800 dark:text-yellow-200">{t('deleteDataWarning')}</p>
|
<p className="warning-text">{t('deleteDataWarning')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -945,6 +1070,20 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
{t('exportOptionSchedules')}
|
{t('exportOptionSchedules')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
{deletionOptions.includeSchedules && (
|
||||||
|
<div className="flex items-center gap-3 pl-8">
|
||||||
|
<Switch
|
||||||
|
id="delete-restore-examples"
|
||||||
|
checked={deletionOptions.restoreExamples ?? false}
|
||||||
|
onCheckedChange={checked =>
|
||||||
|
setDeletionOptions({ ...deletionOptions, restoreExamples: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="delete-restore-examples" className="text-sm text-muted-foreground">
|
||||||
|
{t('deleteRestoreExamples')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Switch
|
<Switch
|
||||||
id="delete-diagram"
|
id="delete-diagram"
|
||||||
|
|||||||
@@ -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-100 dark:bg-blue-900/40 dark:text-blue-200' : 'bg-orange-100 dark:bg-orange-900/40 dark:text-orange-200'}`}
|
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,82 +309,129 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
|||||||
>
|
>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`text-xs ${totalMgDiff > 0 ? 'bg-blue-100 dark:bg-blue-900/40 dark:text-blue-200' : 'bg-orange-100 dark:bg-orange-900/40 dark:text-orange-200'}`}
|
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">
|
||||||
{/* Dose table header */}
|
{/* Daily total warning/error box */}
|
||||||
<div className="grid grid-cols-[120px_1fr_auto] sm:grid-cols-[120px_1fr_auto_auto] gap-2 text-sm font-medium text-muted-foreground">
|
{(isDailyTotalWarning || isDailyTotalError) && (
|
||||||
<div className="flex items-center gap-1">
|
<div className={`p-3 rounded-md text-sm ${isDailyTotalError ? 'error-bg-box' : 'warning-bg-box'}`}>
|
||||||
<span>{t('time')}</span>
|
|
||||||
<Tooltip>
|
{formatText(isDailyTotalError
|
||||||
<TooltipTrigger asChild>
|
? t('errorDailyTotalAbove200mg').replace('{{total}}', dayTotal.toFixed(1))
|
||||||
<Button
|
: t('warningDailyTotalAbove70mg').replace('{{total}}', dayTotal.toFixed(1))
|
||||||
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>
|
||||||
<div>{t('ldx')} (mg)</div>
|
)}
|
||||||
{/* <div className="sm:text-center">
|
{/* Dose table header */}
|
||||||
<Utensils className="h-4 w-4 inline" />
|
<div className="grid items-center gap-0.5 text-sm font-medium text-muted-foreground" style={{gridTemplateColumns: '20px 172px 148px 30px 1fr'}}>
|
||||||
</div> */}
|
<div className="flex justify-center">#</div>{/* Index header */}
|
||||||
<div className="hidden sm:block invisible">-</div>
|
<div>{t('time')}</div>{/* Time header */}
|
||||||
|
<div>{t('ldx')} (mg)</div>{/* LDX header */}
|
||||||
|
<div></div>{/* Buttons column (empty header) */}
|
||||||
</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="space-y-2">
|
<div key={dose.id} className="space-y-2">
|
||||||
<div className="grid grid-cols-[120px_1fr_auto] sm:grid-cols-[120px_1fr_auto_auto] gap-2 items-center">
|
<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)}
|
||||||
@@ -263,28 +440,33 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
|||||||
max={200}
|
max={200}
|
||||||
//unit="mg"
|
//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]"
|
inputWidth="w-[72px]"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2 sm:contents">
|
|
||||||
|
{/* 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>
|
||||||
@@ -293,7 +475,7 @@ const DaySchedule: React.FC<DayScheduleProps> = ({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* 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"
|
||||||
|
|||||||
@@ -109,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>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useWindowSize } from '../hooks/useWindowSize';
|
||||||
import { Card, CardContent } from './ui/card';
|
import { Card, CardContent } from './ui/card';
|
||||||
import { Label } from './ui/label';
|
import { Label } from './ui/label';
|
||||||
import { Switch } from './ui/switch';
|
import { Switch } from './ui/switch';
|
||||||
@@ -22,6 +23,7 @@ import { FormSelect } from './ui/form-select';
|
|||||||
import CollapsibleCardHeader from './ui/collapsible-card-header';
|
import CollapsibleCardHeader from './ui/collapsible-card-header';
|
||||||
import { Info } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
import { getDefaultState } from '../constants/defaults';
|
import { getDefaultState } from '../constants/defaults';
|
||||||
|
import { formatContent, formatText } from '../utils/contentFormatter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to create translation interpolation values for defaults.
|
* Helper function to create translation interpolation values for defaults.
|
||||||
@@ -76,45 +78,6 @@ const tWithDefaults = (translationFn: any, key: string, defaults: Record<string,
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to render tooltip content with inline source links.
|
|
||||||
* Parses [link text](url) markdown-style syntax and renders as clickable links.
|
|
||||||
* @example "See [this study](https://example.com)" → clickable link within tooltip
|
|
||||||
*/
|
|
||||||
const renderTooltipWithLinks = (text: string): React.ReactNode => {
|
|
||||||
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
||||||
const parts: React.ReactNode[] = [];
|
|
||||||
let lastIndex = 0;
|
|
||||||
let match;
|
|
||||||
|
|
||||||
while ((match = linkRegex.exec(text)) !== null) {
|
|
||||||
// Add text before link
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
parts.push(text.substring(lastIndex, match.index));
|
|
||||||
}
|
|
||||||
// Add link
|
|
||||||
parts.push(
|
|
||||||
<a
|
|
||||||
key={`link-${match.index}`}
|
|
||||||
href={match[2]}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline italic text-yellow-300 hover:text-yellow-200 cursor-pointer"
|
|
||||||
>
|
|
||||||
{match[1]}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
lastIndex = linkRegex.lastIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining text
|
|
||||||
if (lastIndex < text.length) {
|
|
||||||
parts.push(text.substring(lastIndex));
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.length > 0 ? parts : text;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Settings = ({
|
const Settings = ({
|
||||||
pkParams,
|
pkParams,
|
||||||
therapeuticRange,
|
therapeuticRange,
|
||||||
@@ -130,6 +93,7 @@ const Settings = ({
|
|||||||
}: any) => {
|
}: any) => {
|
||||||
const { showDayTimeOnXAxis, yAxisMin, yAxisMax, showTemplateDay, simulationDays, displayedDays } = uiSettings;
|
const { showDayTimeOnXAxis, yAxisMin, yAxisMax, showTemplateDay, simulationDays, displayedDays } = uiSettings;
|
||||||
const showDayReferenceLines = (uiSettings as any).showDayReferenceLines ?? true;
|
const showDayReferenceLines = (uiSettings as any).showDayReferenceLines ?? true;
|
||||||
|
const showIntakeTimeLines = (uiSettings as any).showIntakeTimeLines ?? false;
|
||||||
const showTherapeuticRange = (uiSettings as any).showTherapeuticRange ?? true;
|
const showTherapeuticRange = (uiSettings as any).showTherapeuticRange ?? true;
|
||||||
const steadyStateDaysEnabled = (uiSettings as any).steadyStateDaysEnabled ?? true;
|
const steadyStateDaysEnabled = (uiSettings as any).steadyStateDaysEnabled ?? true;
|
||||||
|
|
||||||
@@ -145,20 +109,9 @@ const Settings = ({
|
|||||||
const [therapeuticRangeError, setTherapeuticRangeError] = React.useState<string>('');
|
const [therapeuticRangeError, setTherapeuticRangeError] = React.useState<string>('');
|
||||||
const [yAxisRangeError, setYAxisRangeError] = React.useState<string>('');
|
const [yAxisRangeError, setYAxisRangeError] = React.useState<string>('');
|
||||||
|
|
||||||
// Track window width for responsive tooltip positioning
|
// Track window width for responsive tooltip positioning using debounced hook
|
||||||
const [isNarrowScreen, setIsNarrowScreen] = React.useState(
|
const { width: windowWidth } = useWindowSize(150);
|
||||||
typeof window !== 'undefined' ? window.innerWidth < 640 : false
|
const isNarrowScreen = windowWidth < 640;
|
||||||
);
|
|
||||||
|
|
||||||
// Update narrow screen state on window resize
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
setIsNarrowScreen(window.innerWidth < 640);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Determine tooltip side based on screen width
|
// Determine tooltip side based on screen width
|
||||||
const tooltipSide = isNarrowScreen ? 'top' : 'right';
|
const tooltipSide = isNarrowScreen ? 'top' : 'right';
|
||||||
@@ -319,7 +272,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{tWithDefaults(t, 'showTemplateDayTooltip', defaultsForT)}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'showTemplateDayTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,7 +301,36 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{tWithDefaults(t, 'showDayReferenceLinesTooltip', defaultsForT)}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'showDayReferenceLinesTooltip', defaultsForT))}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
id="showIntakeTimeLines"
|
||||||
|
checked={showIntakeTimeLines}
|
||||||
|
onCheckedChange={checked => onUpdateUiSetting('showIntakeTimeLines', checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showIntakeTimeLines" className="font-medium">
|
||||||
|
{t('showIntakeTimeLines')}
|
||||||
|
</Label>
|
||||||
|
<Tooltip open={openTooltipId === 'showIntakeTimeLines'} onOpenChange={(open) => setOpenTooltipId(open ? 'showIntakeTimeLines' : null)}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTooltipToggle('showIntakeTimeLines')}
|
||||||
|
onTouchStart={handleTooltipToggle('showIntakeTimeLines')}
|
||||||
|
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
|
aria-label={t('showIntakeTimeLinesTooltip')}
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side={tooltipSide}>
|
||||||
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'showIntakeTimeLinesTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -377,7 +359,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{tWithDefaults(t, 'showTherapeuticRangeLinesTooltip', defaultsForT)}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'showTherapeuticRangeLinesTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,7 +382,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{tWithDefaults(t, 'therapeuticRangeTooltip', defaultsForT)}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'therapeuticRangeTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -412,11 +394,12 @@ const Settings = ({
|
|||||||
min={0}
|
min={0}
|
||||||
max={500}
|
max={500}
|
||||||
placeholder={t('min')}
|
placeholder={t('min')}
|
||||||
required={true}
|
required={false}
|
||||||
error={!!therapeuticRangeError || !therapeuticRange.min}
|
error={!!therapeuticRangeError}
|
||||||
errorMessage={therapeuticRangeError || t('errorTherapeuticRangeMinRequired') || 'Minimum therapeutic range is required'}
|
errorMessage={formatText(therapeuticRangeError)}
|
||||||
showResetButton={true}
|
showResetButton={true}
|
||||||
defaultValue={defaultsForT.therapeuticRangeMin}
|
defaultValue={defaultsForT.therapeuticRangeMin}
|
||||||
|
allowEmpty={true}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground">-</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
<FormNumericInput
|
<FormNumericInput
|
||||||
@@ -427,11 +410,12 @@ const Settings = ({
|
|||||||
max={500}
|
max={500}
|
||||||
placeholder={t('max')}
|
placeholder={t('max')}
|
||||||
unit="ng/ml"
|
unit="ng/ml"
|
||||||
required={true}
|
required={false}
|
||||||
error={!!therapeuticRangeError || !therapeuticRange.max}
|
error={!!therapeuticRangeError}
|
||||||
errorMessage={therapeuticRangeError || t('errorTherapeuticRangeMaxRequired') || 'Maximum therapeutic range is required'}
|
errorMessage={formatText(therapeuticRangeError)}
|
||||||
showResetButton={true}
|
showResetButton={true}
|
||||||
defaultValue={defaultsForT.therapeuticRangeMax}
|
defaultValue={defaultsForT.therapeuticRangeMax}
|
||||||
|
allowEmpty={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -456,7 +440,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{tWithDefaults(t, 'displayedDaysTooltip', defaultsForT)}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'displayedDaysTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -468,7 +452,7 @@ const Settings = ({
|
|||||||
max={parseInt(simulationDays, 10) || 3}
|
max={parseInt(simulationDays, 10) || 3}
|
||||||
unit={t('unitDays')}
|
unit={t('unitDays')}
|
||||||
required={true}
|
required={true}
|
||||||
errorMessage={t('errorNumberRequired')}
|
errorMessage={formatText(t('errorNumberRequired'))}
|
||||||
showResetButton={true}
|
showResetButton={true}
|
||||||
defaultValue={defaultsForT.displayedDays}
|
defaultValue={defaultsForT.displayedDays}
|
||||||
/>
|
/>
|
||||||
@@ -490,7 +474,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{tWithDefaults(t, 'yAxisRangeTooltip', defaultsForT)}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'yAxisRangeTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -506,7 +490,7 @@ const Settings = ({
|
|||||||
showResetButton={true}
|
showResetButton={true}
|
||||||
defaultValue={defaultsForT.yAxisMin}
|
defaultValue={defaultsForT.yAxisMin}
|
||||||
warning={!!yAxisRangeError}
|
warning={!!yAxisRangeError}
|
||||||
warningMessage={yAxisRangeError}
|
warningMessage={formatText(yAxisRangeError)}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground">-</span>
|
<span className="text-muted-foreground">-</span>
|
||||||
<FormNumericInput
|
<FormNumericInput
|
||||||
@@ -521,7 +505,7 @@ const Settings = ({
|
|||||||
showResetButton={true}
|
showResetButton={true}
|
||||||
defaultValue={defaultsForT.yAxisMax}
|
defaultValue={defaultsForT.yAxisMax}
|
||||||
warning={!!yAxisRangeError}
|
warning={!!yAxisRangeError}
|
||||||
warningMessage={yAxisRangeError}
|
warningMessage={formatText(yAxisRangeError)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -535,7 +519,7 @@ const Settings = ({
|
|||||||
onValueChange={value => onUpdateUiSetting('showDayTimeOnXAxis', value)}
|
onValueChange={value => onUpdateUiSetting('showDayTimeOnXAxis', value)}
|
||||||
showResetButton={true}
|
showResetButton={true}
|
||||||
defaultValue={defaultsForT.showDayTimeOnXAxis}
|
defaultValue={defaultsForT.showDayTimeOnXAxis}
|
||||||
triggerClassName="w-[360px]"
|
triggerClassName="w-[288px]"
|
||||||
>
|
>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -600,7 +584,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{tWithDefaults(t, 'simulationDurationTooltip', defaultsForT)}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'simulationDurationTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -612,7 +596,7 @@ const Settings = ({
|
|||||||
max={7}
|
max={7}
|
||||||
unit={t('unitDays')}
|
unit={t('unitDays')}
|
||||||
required={true}
|
required={true}
|
||||||
errorMessage={t('errorNumberRequired')}
|
errorMessage={formatText(t('errorNumberRequired'))}
|
||||||
showResetButton={true}
|
showResetButton={true}
|
||||||
defaultValue={defaultsForT.simulationDays}
|
defaultValue={defaultsForT.simulationDays}
|
||||||
/>
|
/>
|
||||||
@@ -652,7 +636,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{tWithDefaults(t, 'steadyStateDaysTooltip', defaultsForT)}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'steadyStateDaysTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -702,7 +686,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'halfLifeTooltip', defaultsForT))}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'halfLifeTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -716,8 +700,8 @@ const Settings = ({
|
|||||||
required={true}
|
required={true}
|
||||||
warning={eliminationWarning && !eliminationExtreme}
|
warning={eliminationWarning && !eliminationExtreme}
|
||||||
error={eliminationExtreme}
|
error={eliminationExtreme}
|
||||||
warningMessage={t('warningEliminationOutOfRange')}
|
warningMessage={formatText(t('warningEliminationOutOfRange'))}
|
||||||
errorMessage={t('errorEliminationHalfLifeRequired')}
|
errorMessage={formatText(t('errorEliminationHalfLifeRequired'))}
|
||||||
showResetButton={true}
|
showResetButton={true}
|
||||||
defaultValue={defaultsForT.damphHalfLife}
|
defaultValue={defaultsForT.damphHalfLife}
|
||||||
/>
|
/>
|
||||||
@@ -742,7 +726,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{tWithDefaults(t, 'conversionHalfLifeTooltip', defaultsForT)}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'conversionHalfLifeTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -755,8 +739,8 @@ const Settings = ({
|
|||||||
unit="h"
|
unit="h"
|
||||||
required={true}
|
required={true}
|
||||||
warning={conversionWarning}
|
warning={conversionWarning}
|
||||||
warningMessage={t('warningConversionOutOfRange')}
|
warningMessage={formatText(t('warningConversionOutOfRange'))}
|
||||||
errorMessage={t('errorConversionHalfLifeRequired')}
|
errorMessage={formatText(t('errorConversionHalfLifeRequired'))}
|
||||||
showResetButton={true}
|
showResetButton={true}
|
||||||
defaultValue={defaultsForT.ldxHalfLife}
|
defaultValue={defaultsForT.ldxHalfLife}
|
||||||
/>
|
/>
|
||||||
@@ -778,7 +762,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{tWithDefaults(t, 'absorptionHalfLifeTooltip', defaultsForT)}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'absorptionHalfLifeTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -791,8 +775,8 @@ const Settings = ({
|
|||||||
unit="h"
|
unit="h"
|
||||||
required={true}
|
required={true}
|
||||||
warning={absorptionWarning}
|
warning={absorptionWarning}
|
||||||
warningMessage={t('warningAbsorptionOutOfRange')}
|
warningMessage={formatText(t('warningAbsorptionOutOfRange'))}
|
||||||
errorMessage={t('errorAbsorptionRateRequired')}
|
errorMessage={formatText(t('errorAbsorptionRateRequired'))}
|
||||||
showResetButton={true}
|
showResetButton={true}
|
||||||
defaultValue={defaultsForT.ldxAbsorptionHalfLife}
|
defaultValue={defaultsForT.ldxAbsorptionHalfLife}
|
||||||
/>
|
/>
|
||||||
@@ -810,8 +794,8 @@ const Settings = ({
|
|||||||
/>
|
/>
|
||||||
{isAdvancedExpanded && (
|
{isAdvancedExpanded && (
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 text-sm">
|
<div className="warning-bg-box rounded-md p-3 text-sm">
|
||||||
<p className="text-yellow-800 dark:text-yellow-200">{t('advancedSettingsWarning')}</p>
|
<p className="warning-text">{t('advancedSettingsWarning')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Standard Volume of Distribution */}
|
{/* Standard Volume of Distribution */}
|
||||||
@@ -831,7 +815,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'standardVdTooltip', {
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'standardVdTooltip', {
|
||||||
...defaultsForT,
|
...defaultsForT,
|
||||||
standardVdValue: pkParams.advanced.standardVd?.preset === 'adult' ? '377' : pkParams.advanced.standardVd?.preset === 'child' ? '175' : pkParams.advanced.standardVd?.customValue || '377',
|
standardVdValue: pkParams.advanced.standardVd?.preset === 'adult' ? '377' : pkParams.advanced.standardVd?.preset === 'child' ? '175' : pkParams.advanced.standardVd?.customValue || '377',
|
||||||
standardVdPreset: t(`standardVdPreset${pkParams.advanced.standardVd?.preset?.charAt(0).toUpperCase()}${pkParams.advanced.standardVd?.preset?.slice(1)}` || 'standardVdPresetAdult')
|
standardVdPreset: t(`standardVdPreset${pkParams.advanced.standardVd?.preset?.charAt(0).toUpperCase()}${pkParams.advanced.standardVd?.preset?.slice(1)}` || 'standardVdPresetAdult')
|
||||||
@@ -844,7 +828,7 @@ const Settings = ({
|
|||||||
onValueChange={(value) => updateAdvanced('standardVd', 'preset', value as 'adult' | 'child' | 'custom' | 'weight-based')}
|
onValueChange={(value) => updateAdvanced('standardVd', 'preset', value as 'adult' | 'child' | 'custom' | 'weight-based')}
|
||||||
showResetButton={true}
|
showResetButton={true}
|
||||||
defaultValue={defaultsForT.standardVdPreset}
|
defaultValue={defaultsForT.standardVdPreset}
|
||||||
triggerClassName="w-[360px]"
|
triggerClassName="w-[288px]"
|
||||||
>
|
>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="adult">{t('standardVdPresetAdult')}</SelectItem>
|
<SelectItem value="adult">{t('standardVdPresetAdult')}</SelectItem>
|
||||||
@@ -854,7 +838,7 @@ const Settings = ({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
{pkParams.advanced.standardVd?.preset === 'weight-based' && (
|
{pkParams.advanced.standardVd?.preset === 'weight-based' && (
|
||||||
<div className="ml-0 mt-2 p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-xs text-blue-800 dark:text-blue-200">
|
<div className="ml-0 mt-2 p-2 info-bg-box rounded text-xs info-text">
|
||||||
ⓘ {t('weightBasedVdInfo')}
|
ⓘ {t('weightBasedVdInfo')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -891,7 +875,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'bodyWeightTooltip', defaultsForT))}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'bodyWeightTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -929,7 +913,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'tmaxDelayTooltip', defaultsForT))}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'tmaxDelayTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -967,7 +951,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{tWithDefaults(t, 'urinePHTooltip', defaultsForT)}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'urinePHTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -979,7 +963,7 @@ const Settings = ({
|
|||||||
}
|
}
|
||||||
showResetButton={true}
|
showResetButton={true}
|
||||||
defaultValue={defaultsForT.urinePh}
|
defaultValue={defaultsForT.urinePh}
|
||||||
triggerClassName="w-[360px]"
|
triggerClassName="w-[288px]"
|
||||||
>
|
>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="normal">{t('urinePHModeNormal')}</SelectItem>
|
<SelectItem value="normal">{t('urinePHModeNormal')}</SelectItem>
|
||||||
@@ -1009,7 +993,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'ageGroupTooltip', defaultsForT))}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'ageGroupTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -1020,7 +1004,7 @@ const Settings = ({
|
|||||||
}}
|
}}
|
||||||
showResetButton={true}
|
showResetButton={true}
|
||||||
defaultValue={defaultsForT.ageGroup}
|
defaultValue={defaultsForT.ageGroup}
|
||||||
triggerClassName="w-[360px]"
|
triggerClassName="w-[288px]"
|
||||||
>
|
>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="adult">{t('ageGroupAdult')}</SelectItem>
|
<SelectItem value="adult">{t('ageGroupAdult')}</SelectItem>
|
||||||
@@ -1061,7 +1045,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'renalFunctionTooltip', defaultsForT))}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'renalFunctionTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -1111,7 +1095,7 @@ const Settings = ({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side={tooltipSide}>
|
<TooltipContent side={tooltipSide}>
|
||||||
<p className="text-xs max-w-xs">{renderTooltipWithLinks(tWithDefaults(t, 'oralBioavailabilityTooltip', defaultsForT))}</p>
|
<p className="text-xs max-w-xs">{formatContent(tWithDefaults(t, 'oralBioavailabilityTooltip', defaultsForT))}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -69,21 +72,14 @@ const SimulationChart = ({
|
|||||||
const dispDays = parseInt(displayedDays, 10) || 2;
|
const dispDays = parseInt(displayedDays, 10) || 2;
|
||||||
const simDays = parseInt(simulationDays, 10) || 3;
|
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);
|
||||||
|
|
||||||
React.useEffect(() => {
|
// Guard against invalid dimensions during initial render
|
||||||
const updateWidth = () => {
|
const yAxisWidth = 80;
|
||||||
if (containerRef.current) {
|
const minContainerWidth = yAxisWidth + 100; // Minimum 100px for chart area
|
||||||
setContainerWidth(containerRef.current.clientWidth);
|
const safeContainerWidth = Math.max(containerWidth, minContainerWidth);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updateWidth();
|
|
||||||
window.addEventListener('resize', updateWidth);
|
|
||||||
return () => window.removeEventListener('resize', updateWidth);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Track current theme for chart styling
|
// Track current theme for chart styling
|
||||||
const [isDarkTheme, setIsDarkTheme] = React.useState(false);
|
const [isDarkTheme, setIsDarkTheme] = React.useState(false);
|
||||||
@@ -105,9 +101,8 @@ const SimulationChart = ({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Y-axis takes ~80px, scrollable area gets the rest
|
// Calculate scrollable width using safe container width
|
||||||
const yAxisWidth = 80;
|
const scrollableWidth = safeContainerWidth - yAxisWidth;
|
||||||
const scrollableWidth = containerWidth - yAxisWidth;
|
|
||||||
|
|
||||||
// Calculate chart width for scrollable area
|
// Calculate chart width for scrollable area
|
||||||
const chartWidth = simDays <= dispDays
|
const chartWidth = simDays <= dispDays
|
||||||
@@ -115,7 +110,7 @@ const SimulationChart = ({
|
|||||||
: Math.ceil((scrollableWidth / dispDays) * simDays);
|
: Math.ceil((scrollableWidth / dispDays) * simDays);
|
||||||
|
|
||||||
// Use shorter captions on narrow containers to reduce wrapping
|
// Use shorter captions on narrow containers to reduce wrapping
|
||||||
const isCompactLabels = containerWidth < 640; // tweakable threshold for mobile
|
const isCompactLabels = safeContainerWidth < 640; // tweakable threshold for mobile
|
||||||
|
|
||||||
// Precompute series labels with translations
|
// 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 }>>(() => {
|
||||||
@@ -156,7 +151,7 @@ const SimulationChart = ({
|
|||||||
const xTickInterval = React.useMemo(() => {
|
const xTickInterval = React.useMemo(() => {
|
||||||
// Aim for ~46px per label to avoid overlaps on narrow screens
|
// Aim for ~46px per label to avoid overlaps on narrow screens
|
||||||
//const MIN_PX_PER_TICK = 46;
|
//const MIN_PX_PER_TICK = 46;
|
||||||
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);
|
||||||
@@ -182,7 +177,8 @@ const SimulationChart = ({
|
|||||||
}, [totalHours, xTickInterval]);
|
}, [totalHours, xTickInterval]);
|
||||||
|
|
||||||
// Custom tick renderer for x-axis to handle 12h/24h/continuous formats and dark mode
|
// Custom tick renderer for x-axis to handle 12h/24h/continuous formats and dark mode
|
||||||
const XAxisTick = (props: any) => {
|
// Memoized to prevent unnecessary re-renders
|
||||||
|
const XAxisTick = React.useCallback((props: any) => {
|
||||||
const { x, y, payload } = props;
|
const { x, y, payload } = props;
|
||||||
const h = payload.value as number;
|
const h = payload.value as number;
|
||||||
let label: string;
|
let label: string;
|
||||||
@@ -209,17 +205,18 @@ const SimulationChart = ({
|
|||||||
{label}
|
{label}
|
||||||
</text>
|
</text>
|
||||||
);
|
);
|
||||||
};
|
}, [showDayTimeOnXAxis, isDarkTheme, t]);
|
||||||
|
|
||||||
// Custom tick renderre for y-axis to handle dark mode
|
// Custom tick renderer for y-axis to handle dark mode
|
||||||
const YAxisTick = (props: any) => {
|
// Memoized to prevent unnecessary re-renders
|
||||||
|
const YAxisTick = React.useCallback((props: any) => {
|
||||||
const { x, y, payload } = props;
|
const { x, y, payload } = props;
|
||||||
return (
|
return (
|
||||||
<text x={x} y={y + 4} textAnchor="end" fill={isDarkTheme ? '#ccc' : '#666'}>
|
<text x={x} y={y + 4} textAnchor="end" fill={isDarkTheme ? '#ccc' : '#666'}>
|
||||||
{payload.value}
|
{payload.value}
|
||||||
</text>
|
</text>
|
||||||
);
|
);
|
||||||
};
|
}, [isDarkTheme]);
|
||||||
|
|
||||||
// Calculate Y-axis domain based on data and user settings
|
// Calculate Y-axis domain based on data and user settings
|
||||||
const yAxisDomain = React.useMemo(() => {
|
const yAxisDomain = React.useMemo(() => {
|
||||||
@@ -277,7 +274,7 @@ const SimulationChart = ({
|
|||||||
// User set yAxisMax explicitly
|
// User set yAxisMax explicitly
|
||||||
// Add padding to dataMax and use the higher of manual or (dataMax + padding)
|
// Add padding to dataMax and use the higher of manual or (dataMax + padding)
|
||||||
const range = dataMax - dataMin;
|
const range = dataMax - dataMin;
|
||||||
const padding = range * 0.1;
|
const padding = range * 0.05;
|
||||||
const dataMaxWithPadding = dataMax + padding;
|
const dataMaxWithPadding = dataMax + padding;
|
||||||
// Use manual max only if it's higher than dataMax + padding
|
// Use manual max only if it's higher than dataMax + padding
|
||||||
domainMax = Math.max(numMax, dataMaxWithPadding);
|
domainMax = Math.max(numMax, dataMaxWithPadding);
|
||||||
@@ -286,9 +283,9 @@ const SimulationChart = ({
|
|||||||
domainMax = numMax;
|
domainMax = numMax;
|
||||||
}
|
}
|
||||||
} else if (dataMax !== -Infinity) { // data exists
|
} else if (dataMax !== -Infinity) { // data exists
|
||||||
// Auto mode: add 10% padding above
|
// Auto mode: add 5% padding above
|
||||||
const range = dataMax - dataMin;
|
const range = dataMax - dataMin;
|
||||||
const padding = range * 0.1;
|
const padding = range * 0.05;
|
||||||
domainMax = dataMax + padding;
|
domainMax = dataMax + padding;
|
||||||
} else { // no data
|
} else { // no data
|
||||||
domainMax = 100;
|
domainMax = 100;
|
||||||
@@ -347,6 +344,44 @@ const SimulationChart = ({
|
|||||||
}
|
}
|
||||||
}, [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();
|
||||||
@@ -415,6 +450,15 @@ const SimulationChart = ({
|
|||||||
);
|
);
|
||||||
}, [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
|
// 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">
|
||||||
@@ -500,8 +544,8 @@ const SimulationChart = ({
|
|||||||
domain={yAxisDomain as any}
|
domain={yAxisDomain as any}
|
||||||
axisLine={{ stroke: isDarkTheme ? '#ccc' : '#666' }}
|
axisLine={{ stroke: isDarkTheme ? '#ccc' : '#666' }}
|
||||||
tick={<YAxisTick />}
|
tick={<YAxisTick />}
|
||||||
tickCount={16}
|
tickCount={20}
|
||||||
interval={0}
|
interval={1}
|
||||||
allowDecimals={false}
|
allowDecimals={false}
|
||||||
allowDataOverflow={false}
|
allowDataOverflow={false}
|
||||||
/>
|
/>
|
||||||
@@ -565,7 +609,7 @@ const SimulationChart = ({
|
|||||||
style={{ stroke: isDarkTheme ? '#666' : '#ccc' }}
|
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 = "";
|
||||||
@@ -596,20 +640,20 @@ const SimulationChart = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{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"
|
||||||
@@ -617,6 +661,43 @@ const SimulationChart = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{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
|
||||||
@@ -694,6 +775,27 @@ const SimulationChart = ({
|
|||||||
</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;
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ interface NumericInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElem
|
|||||||
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')
|
inputWidth?: string // Custom width for the input field (e.g., 'w-16', 'w-20')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|||||||
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
|
inputWidth = 'w-20', // Default width
|
||||||
className,
|
className,
|
||||||
@@ -200,21 +200,6 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|||||||
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)}
|
|
||||||
disabled={isAtMin}
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
<Minus className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Input
|
<Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -225,13 +210,24 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
inputWidth, "h-9 z-10",
|
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"
|
||||||
@@ -239,8 +235,8 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|||||||
className={cn(
|
className={cn(
|
||||||
"h-9 w-9",
|
"h-9 w-9",
|
||||||
showResetButton ? "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}
|
disabled={isAtMax}
|
||||||
@@ -255,11 +251,7 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|||||||
tooltip={t('buttonResetToDefault')}
|
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",
|
|
||||||
hasError && "border-destructive",
|
|
||||||
hasWarning && !hasError && "border-yellow-500"
|
|
||||||
)}
|
|
||||||
onClick={() => onChange(String(defaultValue ?? ''))}
|
onClick={() => onChange(String(defaultValue ?? ''))}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
/>
|
/>
|
||||||
@@ -267,12 +259,12 @@ const FormNumericInput = React.forwardRef<HTMLInputElement, NumericInputProps>(
|
|||||||
</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-20 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-20 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const FormSelect: React.FC<FormSelectProps> = ({
|
|||||||
<Select value={value} onValueChange={onValueChange}>
|
<Select value={value} onValueChange={onValueChange}>
|
||||||
<SelectTrigger className={cn(
|
<SelectTrigger className={cn(
|
||||||
showResetButton && "rounded-r-none border-r-0 z-10",
|
showResetButton && "rounded-r-none border-r-0 z-10",
|
||||||
|
"bg-background",
|
||||||
triggerClassName
|
triggerClassName
|
||||||
)}>
|
)}>
|
||||||
<SelectValue placeholder={placeholder} />
|
<SelectValue placeholder={placeholder} />
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -89,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,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>) => {
|
||||||
@@ -162,6 +169,8 @@ const FormTimeInput = React.forwardRef<HTMLInputElement, TimeInputProps>(
|
|||||||
// Commit the current value (already updated in real-time) and close
|
// Commit the current value (already updated in real-time) and close
|
||||||
setOriginalValue('') // Clear original so revert doesn't happen on close
|
setOriginalValue('') // Clear original so revert doesn't happen on close
|
||||||
setIsPickerOpen(false)
|
setIsPickerOpen(false)
|
||||||
|
// Call optional onBlur callback after applying picker changes
|
||||||
|
onBlur?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
@@ -199,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}
|
||||||
/>
|
/>
|
||||||
@@ -219,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
|
||||||
@@ -234,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')}
|
||||||
@@ -243,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
|
||||||
@@ -255,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')}
|
||||||
@@ -289,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,11 +26,15 @@ const versionInfo = versionJsonDefault && Object.keys(versionJsonDefault).length
|
|||||||
gitDate: 'unknown',
|
gitDate: 'unknown',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v9'; // Incremented for urinePh mode structure change
|
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;
|
||||||
@@ -77,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;
|
||||||
}
|
}
|
||||||
@@ -95,6 +107,7 @@ export interface UiSettings {
|
|||||||
simulationDays: string;
|
simulationDays: string;
|
||||||
displayedDays: string;
|
displayedDays: string;
|
||||||
showDayReferenceLines?: boolean;
|
showDayReferenceLines?: boolean;
|
||||||
|
showIntakeTimeLines?: boolean;
|
||||||
showTherapeuticRange?: boolean;
|
showTherapeuticRange?: boolean;
|
||||||
steadyStateDaysEnabled?: boolean;
|
steadyStateDaysEnabled?: boolean;
|
||||||
stickyChart: boolean;
|
stickyChart: boolean;
|
||||||
@@ -103,7 +116,9 @@ export interface UiSettings {
|
|||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
pkParams: PkParams;
|
pkParams: PkParams;
|
||||||
days: DayGroup[];
|
days: DayGroup[]; // Kept for backwards compatibility during migration
|
||||||
|
profiles: ScheduleProfile[];
|
||||||
|
activeProfileId: string;
|
||||||
steadyStateConfig: SteadyStateConfig;
|
steadyStateConfig: SteadyStateConfig;
|
||||||
therapeuticRange: TherapeuticRange;
|
therapeuticRange: TherapeuticRange;
|
||||||
doseIncrement: string;
|
doseIncrement: string;
|
||||||
@@ -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: {
|
||||||
@@ -144,17 +213,9 @@ export const getDefaultState = (): AppState => ({
|
|||||||
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: '25' },
|
|
||||||
{ id: 'dose-2', time: '14:30', ldx: '15' },
|
|
||||||
{ id: 'dose-4', time: '22:15', ldx: '15' },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
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: '' }, // users should personalize based on their response
|
therapeuticRange: { min: '', max: '' }, // users should personalize based on their response
|
||||||
doseIncrement: '2.5',
|
doseIncrement: '2.5',
|
||||||
@@ -167,8 +228,10 @@ export const getDefaultState = (): AppState => ({
|
|||||||
simulationDays: '5',
|
simulationDays: '5',
|
||||||
displayedDays: '2',
|
displayedDays: '2',
|
||||||
showTherapeuticRange: false,
|
showTherapeuticRange: false,
|
||||||
|
showIntakeTimeLines: false,
|
||||||
steadyStateDaysEnabled: true,
|
steadyStateDaysEnabled: true,
|
||||||
stickyChart: false,
|
stickyChart: false,
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -94,51 +94,45 @@ export const useAppState = () => {
|
|||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate basic pkParams
|
// Migrate from old days-only format to profile-based format
|
||||||
if (migratedPkParams.basic) {
|
let migratedProfiles: ScheduleProfile[] = defaults.profiles;
|
||||||
migratedPkParams.basic.eliminationHalfLife = validateNumericField(
|
let migratedActiveProfileId: string = defaults.activeProfileId;
|
||||||
migratedPkParams.basic.eliminationHalfLife,
|
let migratedDays: DayGroup[] = defaults.days;
|
||||||
defaults.pkParams.basic.eliminationHalfLife
|
|
||||||
);
|
if (parsedState.profiles && Array.isArray(parsedState.profiles)) {
|
||||||
migratedPkParams.basic.bodyWeight = validateNumericField(
|
// New format with profiles
|
||||||
migratedPkParams.basic.bodyWeight,
|
migratedProfiles = parsedState.profiles;
|
||||||
defaults.pkParams.basic.bodyWeight
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate advanced pkParams
|
// Set days from active profile
|
||||||
if (migratedPkParams.advanced) {
|
migratedDays = activeProfile?.days || defaults.days;
|
||||||
migratedPkParams.advanced.conversionEfficiency = validateNumericField(
|
} else if (parsedState.days) {
|
||||||
migratedPkParams.advanced.conversionEfficiency,
|
// Old format: migrate days to default profile
|
||||||
defaults.pkParams.advanced.conversionEfficiency
|
const now = new Date().toISOString();
|
||||||
);
|
migratedProfiles = [{
|
||||||
migratedPkParams.advanced.bioavailability = validateNumericField(
|
id: `profile-migrated-${Date.now()}`,
|
||||||
migratedPkParams.advanced.bioavailability,
|
name: 'Default',
|
||||||
defaults.pkParams.advanced.bioavailability
|
days: parsedState.days,
|
||||||
);
|
createdAt: now,
|
||||||
migratedPkParams.advanced.customVolumeOfDistribution = validateNumericField(
|
modifiedAt: now
|
||||||
migratedPkParams.advanced.customVolumeOfDistribution,
|
}];
|
||||||
defaults.pkParams.advanced.customVolumeOfDistribution
|
migratedActiveProfileId = migratedProfiles[0].id;
|
||||||
);
|
migratedDays = parsedState.days;
|
||||||
migratedPkParams.advanced.absorptionDelay = validateNumericField(
|
|
||||||
migratedPkParams.advanced.absorptionDelay,
|
|
||||||
defaults.pkParams.advanced.absorptionDelay
|
|
||||||
);
|
|
||||||
migratedPkParams.advanced.absorptionRateConstant = validateNumericField(
|
|
||||||
migratedPkParams.advanced.absorptionRateConstant,
|
|
||||||
defaults.pkParams.advanced.absorptionRateConstant
|
|
||||||
);
|
|
||||||
migratedPkParams.advanced.mealDelayFactor = validateNumericField(
|
|
||||||
migratedPkParams.advanced.mealDelayFactor,
|
|
||||||
defaults.pkParams.advanced.mealDelayFactor
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAppState({
|
setAppState({
|
||||||
...defaults,
|
...defaults,
|
||||||
...parsedState,
|
...parsedState,
|
||||||
pkParams: migratedPkParams,
|
pkParams: migratedPkParams,
|
||||||
days: parsedState.days || defaults.days,
|
days: migratedDays,
|
||||||
|
profiles: migratedProfiles,
|
||||||
|
activeProfileId: migratedActiveProfileId,
|
||||||
uiSettings: migratedUiSettings,
|
uiSettings: migratedUiSettings,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -154,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,
|
||||||
@@ -258,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] };
|
||||||
@@ -343,6 +360,153 @@ export const useAppState = () => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Profile management functions
|
||||||
|
const getActiveProfile = (): ScheduleProfile | undefined => {
|
||||||
|
return appState.profiles.find(p => p.id === appState.activeProfileId);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
||||||
appState,
|
appState,
|
||||||
isLoaded,
|
isLoaded,
|
||||||
@@ -356,6 +520,15 @@ export const useAppState = () => {
|
|||||||
removeDoseFromDay,
|
removeDoseFromDay,
|
||||||
updateDoseInDay,
|
updateDoseInDay,
|
||||||
updateDoseFieldInDay,
|
updateDoseFieldInDay,
|
||||||
sortDosesInDay
|
sortDosesInDay,
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
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,7 +10,7 @@ export const de = {
|
|||||||
lisdexamfetamine: "Lisdexamfetamin",
|
lisdexamfetamine: "Lisdexamfetamin",
|
||||||
lisdexamfetamineShort: "LDX",
|
lisdexamfetamineShort: "LDX",
|
||||||
both: "Beide",
|
both: "Beide",
|
||||||
regularPlanOverlayShort: "Reg.",
|
regularPlanOverlayShort: "Basis",
|
||||||
|
|
||||||
// Language selector
|
// Language selector
|
||||||
languageSelectorLabel: "Sprache",
|
languageSelectorLabel: "Sprache",
|
||||||
@@ -23,7 +23,7 @@ export const de = {
|
|||||||
themeSelectorSystem: "💻 System",
|
themeSelectorSystem: "💻 System",
|
||||||
|
|
||||||
// Dose Schedule
|
// Dose Schedule
|
||||||
myPlan: "Mein Plan",
|
myPlan: "Mein Zeitplan",
|
||||||
morning: "Morgens",
|
morning: "Morgens",
|
||||||
midday: "Mittags",
|
midday: "Mittags",
|
||||||
afternoon: "Nachmittags",
|
afternoon: "Nachmittags",
|
||||||
@@ -32,8 +32,33 @@ export const de = {
|
|||||||
doseWithFood: "Mit Nahrung eingenommen (verzögert Absorption ~1h)",
|
doseWithFood: "Mit Nahrung eingenommen (verzögert Absorption ~1h)",
|
||||||
doseFasted: "Nüchtern eingenommen (normale Absorption)",
|
doseFasted: "Nüchtern eingenommen (normale Absorption)",
|
||||||
|
|
||||||
|
// Schedule Management
|
||||||
|
savedPlans: "Gespeicherte Zeitpläne",
|
||||||
|
profileSaveAsNewProfile: "Als neuen Zeitplan speichern",
|
||||||
|
profileSave: "Änderungen im aktuellen Zeitplan speichern",
|
||||||
|
profileSaveAs: "Neuen Zeitplan mit aktueller Konfiguration erstellen",
|
||||||
|
profileRename: "Diesen Zeitplan umbenennen",
|
||||||
|
profileRenameHelp: "Geben Sie einen neuen Namen für den Zeitplan ein und drücken Sie Enter oder klicken Sie auf Speichern",
|
||||||
|
profileRenamePlaceholder: "Neuer Name für den Zeitplan...",
|
||||||
|
profileDelete: "Diesen Zeitplan löschen",
|
||||||
|
profileDeleteDisabled: "Der letzte Zeitplan kann nicht gelöscht werden",
|
||||||
|
profileDeleteConfirm: "Möchten Sie den Zeitplan '{name}' wirklich löschen?",
|
||||||
|
profileSaveAsPlaceholder: "Name für den neuen Zeitplan...",
|
||||||
|
profileSaveAsHelp: "Geben Sie einen Namen für den neuen Zeitplan ein und drücken Sie Enter oder klicken Sie auf Speichern",
|
||||||
|
profileNameAlreadyExists: "Ein Zeitplan mit diesem Namen existiert bereits",
|
||||||
|
profileSwitchUnsavedConfirm: "Sie haben ungespeicherte Änderungen. Beim Wechseln des Zeitplans gehen diese verloren. Fortfahren?",
|
||||||
|
profiles: "Zeitpläne",
|
||||||
|
cancel: "Abbrechen",
|
||||||
|
|
||||||
|
// Export/Import schedules
|
||||||
|
exportAllProfiles: "Alle Zeitpläne exportieren",
|
||||||
|
exportAllProfilesTooltip: "__Wenn aktiviert:__ Exportiert alle gespeicherten Zeitpläne.\\n\\n__Wenn deaktiviert:__ Exportiert nur den aktuell aktiven Zeitplan. Wenn der aktive Zeitplan ungespeicherte Änderungen hat, werden diese im Export enthalten sein.",
|
||||||
|
mergeProfiles: "Mit vorhandenen Zeitplänen zusammenführen",
|
||||||
|
mergeProfilesTooltip: "Wenn aktiviert, werden importierte Zeitpläne zu Ihren vorhandenen hinzugefügt. Wenn deaktiviert, werden alle aktuellen Zeitpläne ersetzt.\\n\\n__Standard:__ **deaktiviert** (alle ersetzen)",
|
||||||
|
deleteRestoreExamples: "Beispielzeitpläne nach Löschung wiederherstellen",
|
||||||
|
|
||||||
// Deviations
|
// Deviations
|
||||||
deviationsFromPlan: "Abweichungen vom Plan",
|
deviationsFromPlan: "Abweichungen vom Zeitplan",
|
||||||
addDeviation: "Abweichung hinzufügen",
|
addDeviation: "Abweichung hinzufügen",
|
||||||
day: "Tag",
|
day: "Tag",
|
||||||
additional: "Zusätzlich",
|
additional: "Zusätzlich",
|
||||||
@@ -53,13 +78,13 @@ export const de = {
|
|||||||
axisLabelHours: "Stunden (h)",
|
axisLabelHours: "Stunden (h)",
|
||||||
axisLabelTimeOfDay: "Tageszeit (h)",
|
axisLabelTimeOfDay: "Tageszeit (h)",
|
||||||
tickNoon: "Mittag",
|
tickNoon: "Mittag",
|
||||||
refLineRegularPlan: "Regulär",
|
refLineRegularPlan: "Basis",
|
||||||
refLineNoDeviation: "Regulär",
|
refLineNoDeviation: "Basis",
|
||||||
refLineRecovering: "Erholung",
|
refLineRecovering: "Erholung",
|
||||||
refLineIrregularIntake: "Irregulär",
|
refLineIrregularIntake: "Irregulär",
|
||||||
refLineDayX: "T{{x}}",
|
refLineDayX: "T{{x}}",
|
||||||
refLineRegularPlanShort: "(Reg.)",
|
refLineRegularPlanShort: "(Basis)",
|
||||||
refLineNoDeviationShort: "(Reg.)",
|
refLineNoDeviationShort: "(Basis)",
|
||||||
refLineRecoveringShort: "(Erh.)",
|
refLineRecoveringShort: "(Erh.)",
|
||||||
refLineIrregularIntakeShort: "(Irr.)",
|
refLineIrregularIntakeShort: "(Irr.)",
|
||||||
refLineDayShort: "T{{x}}",
|
refLineDayShort: "T{{x}}",
|
||||||
@@ -67,7 +92,7 @@ export const de = {
|
|||||||
refLineMax: "Max",
|
refLineMax: "Max",
|
||||||
pinChart: "Diagramm oben fixieren",
|
pinChart: "Diagramm oben fixieren",
|
||||||
unpinChart: "Diagramm freigeben",
|
unpinChart: "Diagramm freigeben",
|
||||||
stickyChartTooltip: "Diagramm beim Scrollen durch die Einstellungen sichtbar halten, um Änderungen in Echtzeit zu sehen. Standard: aus.",
|
stickyChartTooltip: "Diagramm beim Scrollen durch die Einstellungen sichtbar halten, um Änderungen in Echtzeit zu sehen.\\n\\n__Standard:__ **aus**",
|
||||||
chartViewDamphTooltip: "Nur den aktiven Metaboliten (d-Amphetamin) im Konzentrationsverlauf anzeigen",
|
chartViewDamphTooltip: "Nur den aktiven Metaboliten (d-Amphetamin) im Konzentrationsverlauf anzeigen",
|
||||||
chartViewLdxTooltip: "Nur das Prodrug (Lisdexamfetamin) im Konzentrationsverlauf anzeigen",
|
chartViewLdxTooltip: "Nur das Prodrug (Lisdexamfetamin) im Konzentrationsverlauf anzeigen",
|
||||||
chartViewBothTooltip: "Sowohl d-Amphetamin als auch Lisdexamfetamin gemeinsam anzeigen",
|
chartViewBothTooltip: "Sowohl d-Amphetamin als auch Lisdexamfetamin gemeinsam anzeigen",
|
||||||
@@ -79,13 +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. Gewichtsbasierte Skalierung: ~5,4 L/kg (für Erwachsene >18 Jahre basierend auf [Populations-Pharmakokinetik](https://pmc.ncbi.nlm.nih.gov/articles/PMC5572767/)). 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)",
|
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. Diese Option ist für Erwachsene (>18 Jahre) basierend auf der Populations-PK-Studie vorgesehen. Für pädiatrische Patienten verwenden Sie die Voreinstellung 'Kinder'.",
|
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...)",
|
||||||
@@ -93,76 +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 (d-Amphetamin)",
|
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). 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. 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. Saurer Urin (<6) erhöht Elimination (schnellere Ausscheidung, t½ ~7-9h). Normaler pH (6-7,5) hält Basis-Elimination (~11h). Alkalischer Urin (>7,5) reduziert Elimination (langsamere Ausscheidung, t½ ~13-15h). Typischer Bereich: 5,5-8,0. Standard: Normaler pH (6-7,5).",
|
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",
|
urinePHMode: "pH-Effekt",
|
||||||
urinePHModeNormal: "Normal (pH 6-7,5, t½ 11h)",
|
urinePHModeNormal: "Normal (pH 6-7,5, t½ 11h)",
|
||||||
urinePHModeAcidic: "Sauer (pH <6, schnellere Elimination)",
|
urinePHModeAcidic: "Sauer (pH <6, schnellere Elimination)",
|
||||||
urinePHModeAlkaline: "Alkalisch (pH >7,5, langsamere 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)",
|
||||||
@@ -171,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",
|
||||||
@@ -213,7 +238,7 @@ export const de = {
|
|||||||
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)",
|
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 Plänen mit anderen.",
|
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",
|
||||||
@@ -296,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",
|
||||||
@@ -312,17 +339,17 @@ 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",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const en = {
|
|||||||
lisdexamfetamine: "Lisdexamfetamine",
|
lisdexamfetamine: "Lisdexamfetamine",
|
||||||
lisdexamfetamineShort: "LDX",
|
lisdexamfetamineShort: "LDX",
|
||||||
both: "Both",
|
both: "Both",
|
||||||
regularPlanOverlayShort: "Reg.",
|
regularPlanOverlayShort: "Base",
|
||||||
|
|
||||||
// Language selector
|
// Language selector
|
||||||
languageSelectorLabel: "Language",
|
languageSelectorLabel: "Language",
|
||||||
@@ -23,7 +23,7 @@ export const en = {
|
|||||||
themeSelectorSystem: "💻 System",
|
themeSelectorSystem: "💻 System",
|
||||||
|
|
||||||
// Dose Schedule
|
// Dose Schedule
|
||||||
myPlan: "My Plan",
|
myPlan: "My Schedule",
|
||||||
morning: "Morning",
|
morning: "Morning",
|
||||||
midday: "Midday",
|
midday: "Midday",
|
||||||
afternoon: "Afternoon",
|
afternoon: "Afternoon",
|
||||||
@@ -32,12 +32,37 @@ export const en = {
|
|||||||
doseWithFood: "Taken with food (delays absorption ~1h)",
|
doseWithFood: "Taken with food (delays absorption ~1h)",
|
||||||
doseFasted: "Taken fasted (normal absorption)",
|
doseFasted: "Taken fasted (normal absorption)",
|
||||||
|
|
||||||
|
// Schedule Management
|
||||||
|
savedPlans: "Saved Schedules",
|
||||||
|
profileSaveAsNewProfile: "Save as new schedule",
|
||||||
|
profileSave: "Save changes to current schedule",
|
||||||
|
profileSaveAs: "Create new schedule with current configuration",
|
||||||
|
profileRename: "Rename this schedule",
|
||||||
|
profileRenameHelp: "Enter a new name for the schedule and press Enter or click Save",
|
||||||
|
profileRenamePlaceholder: "New name for the schedule...",
|
||||||
|
profileDelete: "Delete this schedule",
|
||||||
|
profileDeleteDisabled: "Cannot delete the last schedule",
|
||||||
|
profileDeleteConfirm: "Are you sure you want to delete the schedule '{name}'?",
|
||||||
|
profileSaveAsPlaceholder: "Name for the new schedule...",
|
||||||
|
profileSaveAsHelp: "Enter a name for the new schedule and press Enter or click Save",
|
||||||
|
profileNameAlreadyExists: "A schedule with this name already exists",
|
||||||
|
profileSwitchUnsavedConfirm: "You have unsaved changes. Switching schedules will discard them. Continue?",
|
||||||
|
profiles: "schedules",
|
||||||
|
cancel: "Cancel",
|
||||||
|
|
||||||
|
// Export/Import schedules
|
||||||
|
exportAllProfiles: "Export all schedules",
|
||||||
|
exportAllProfilesTooltip: "__When enabled:__ Exports all saved schedules.\\n\\n__When disabled:__ Exports only the currently active schedule. If the active schedule has unsaved changes, those changes will be included in the export.",
|
||||||
|
mergeProfiles: "Merge with existing schedules",
|
||||||
|
mergeProfilesTooltip: "If enabled, imported schedules will be added to your existing ones. If disabled, all current schedules will be replaced.\\n\\n__Default:__ **disabled** (replace all)",
|
||||||
|
deleteRestoreExamples: "Restore example schedules after deletion",
|
||||||
|
|
||||||
// Deviations
|
// Deviations
|
||||||
deviationsFromPlan: "Deviations from Plan",
|
deviationsFromPlan: "Deviations from Schedule",
|
||||||
addDeviation: "Add Deviation",
|
addDeviation: "Add Deviation",
|
||||||
day: "Day",
|
day: "Day",
|
||||||
additional: "Additional",
|
additional: "Additional",
|
||||||
additionalTooltip: "Mark this if it was an extra dose instead of a replacement for a planned one.",
|
additionalTooltip: "Mark this if it was an extra dose instead of a replacement for a scheduled one.",
|
||||||
|
|
||||||
// Suggestions
|
// Suggestions
|
||||||
whatIf: "What if?",
|
whatIf: "What if?",
|
||||||
@@ -53,13 +78,13 @@ export const en = {
|
|||||||
axisLabelHours: "Hours (h)",
|
axisLabelHours: "Hours (h)",
|
||||||
axisLabelTimeOfDay: "Time of Day (h)",
|
axisLabelTimeOfDay: "Time of Day (h)",
|
||||||
tickNoon: "Noon",
|
tickNoon: "Noon",
|
||||||
refLineRegularPlan: "Regular",
|
refLineRegularPlan: "Baseline",
|
||||||
refLineNoDeviation: "Regular",
|
refLineNoDeviation: "Baseline",
|
||||||
refLineRecovering: "Recovering",
|
refLineRecovering: "Recovering",
|
||||||
refLineIrregularIntake: "Irregular",
|
refLineIrregularIntake: "Irregular",
|
||||||
refLineDayX: "D{{x}}",
|
refLineDayX: "D{{x}}",
|
||||||
refLineRegularPlanShort: "(Reg.)",
|
refLineRegularPlanShort: "(Base)",
|
||||||
refLineNoDeviationShort: "(Reg.)", // currently the same as above (day# > 1 with curve identical to day1 / regular plan)
|
refLineNoDeviationShort: "(Base)", // currently the same as above (day# > 1 with curve identical to day1 / baseline schedule)
|
||||||
refLineRecoveringShort: "(Rec.)",
|
refLineRecoveringShort: "(Rec.)",
|
||||||
refLineIrregularIntakeShort: "(Ireg.)",
|
refLineIrregularIntakeShort: "(Ireg.)",
|
||||||
refLineDayShort: "D{{x}}",
|
refLineDayShort: "D{{x}}",
|
||||||
@@ -67,7 +92,7 @@ export const en = {
|
|||||||
refLineMax: "Max",
|
refLineMax: "Max",
|
||||||
pinChart: "Pin chart to top",
|
pinChart: "Pin chart to top",
|
||||||
unpinChart: "Unpin chart",
|
unpinChart: "Unpin chart",
|
||||||
stickyChartTooltip: "Keep chart visible while scrolling through settings for real-time feedback. Default: off.",
|
stickyChartTooltip: "Keep chart visible while scrolling through settings for real-time feedback.\\n\\n__Default:__ **off**",
|
||||||
chartViewDamphTooltip: "Show only the active metabolite (d-Amphetamine) concentration profile",
|
chartViewDamphTooltip: "Show only the active metabolite (d-Amphetamine) concentration profile",
|
||||||
chartViewLdxTooltip: "Show only the prodrug (Lisdexamfetamine) concentration profile",
|
chartViewLdxTooltip: "Show only the prodrug (Lisdexamfetamine) concentration profile",
|
||||||
chartViewBothTooltip: "Show both d-Amphetamine and Lisdexamfetamine profiles together",
|
chartViewBothTooltip: "Show both d-Amphetamine and Lisdexamfetamine profiles together",
|
||||||
@@ -78,13 +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. Weight-based scaling: ~5.4 L/kg (intended for adults >18 years based on [population PK analysis](https://pmc.ncbi.nlm.nih.gov/articles/PMC5572767/)). 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)",
|
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). Lighter persons → higher peaks, heavier → lower peaks. This option is intended for adults (>18 years) based on the population PK study. For pediatric patients, use the 'Child' preset.",
|
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...)",
|
||||||
@@ -92,75 +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 (d-Amphetamine)",
|
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/). 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. 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. Acidic urine (<6) increases elimination (faster clearance, t½ ~7-9h). Normal pH (6-7.5) maintains baseline elimination (~11h). Alkaline urine (>7.5) reduces elimination (slower clearance, t½ ~13-15h). Typical range: 5.5-8.0. Default: Normal pH (6-7.5).",
|
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",
|
urinePHMode: "pH Effect",
|
||||||
urinePHModeNormal: "Normal (pH 6-7.5, t½ 11h)",
|
urinePHModeNormal: "Normal (pH 6-7.5, t½ 11h)",
|
||||||
urinePHModeAcidic: "Acidic (pH <6, faster elimination)",
|
urinePHModeAcidic: "Acidic (pH <6, faster elimination)",
|
||||||
urinePHModeAlkaline: "Alkaline (pH >7.5, slower 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)",
|
||||||
@@ -169,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",
|
||||||
@@ -205,13 +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)",
|
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 plans with others.",
|
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",
|
||||||
@@ -292,10 +317,12 @@ export const en = {
|
|||||||
// 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",
|
||||||
@@ -313,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",
|
||||||
@@ -327,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;
|
||||||
|
|||||||
@@ -48,6 +48,10 @@
|
|||||||
--accent-foreground: 0 0% 90%;
|
--accent-foreground: 0 0% 90%;
|
||||||
--destructive: 0 84% 60%;
|
--destructive: 0 84% 60%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--bubble-error: 0 84% 60%;
|
||||||
|
--bubble-error-foreground: 0 0% 98%;
|
||||||
|
--bubble-warning: 42 100% 60%;
|
||||||
|
--bubble-warning-foreground: 0 0% 98%;
|
||||||
--border: 0 0% 25%;
|
--border: 0 0% 25%;
|
||||||
--input: 0 0% 25%;
|
--input: 0 0% 25%;
|
||||||
--ring: 0 0% 40%;
|
--ring: 0 0% 40%;
|
||||||
@@ -68,3 +72,82 @@
|
|||||||
font-feature-settings: "rlig" 1, "calt" 1;
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Error message bubble - validation popups */
|
||||||
|
.error-bubble {
|
||||||
|
@apply bg-[hsl(var(--background))] text-[hsl(var(--foreground))] border border-red-500 dark:border-red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning message bubble - validation popups */
|
||||||
|
.warning-bubble {
|
||||||
|
@apply bg-[hsl(var(--background))] text-[hsl(var(--foreground))] border border-amber-500 dark:border-amber-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error border - for input fields with errors */
|
||||||
|
.error-border {
|
||||||
|
@apply !border-red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning border - for input fields with warnings */
|
||||||
|
.warning-border {
|
||||||
|
@apply !border-amber-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info border - for input fields with informational messages */
|
||||||
|
.info-border {
|
||||||
|
@apply !border-blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error background box - for static error/warning sections */
|
||||||
|
.error-bg-box {
|
||||||
|
@apply bg-[hsl(var(--background))] border border-red-500 dark:border-red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning background box - for static warning sections */
|
||||||
|
.warning-bg-box {
|
||||||
|
@apply bg-[hsl(var(--background))] border border-amber-500 dark:border-amber-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info background box - for informational sections */
|
||||||
|
.info-bg-box {
|
||||||
|
@apply bg-[hsl(var(--background))] border border-blue-500 dark:border-blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error text - for inline error text */
|
||||||
|
.error-text {
|
||||||
|
@apply text-[hsl(var(--foreground))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning text - for inline warning text */
|
||||||
|
.warning-text {
|
||||||
|
@apply text-[hsl(var(--foreground))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info text - for inline info text */
|
||||||
|
.info-text {
|
||||||
|
@apply text-[hsl(var(--foreground))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge variants for validation states */
|
||||||
|
.badge-error {
|
||||||
|
@apply border-red-500 bg-red-500/20 text-red-700 dark:text-red-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
@apply border-amber-500 bg-amber-500/20 text-amber-700 dark:text-amber-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
@apply border-blue-500 bg-blue-500/20 text-blue-700 dark:text-blue-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge variants for trend indicators */
|
||||||
|
.badge-trend-up {
|
||||||
|
@apply bg-blue-100 dark:bg-blue-900/60 text-blue-700 dark:text-blue-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-trend-down {
|
||||||
|
@apply bg-orange-100 dark:bg-orange-900/60 text-orange-700 dark:text-orange-200;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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'];
|
||||||
};
|
};
|
||||||
@@ -49,6 +51,8 @@ export interface ExportData {
|
|||||||
|
|
||||||
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;
|
||||||
@@ -56,6 +60,10 @@ export interface ExportOptions {
|
|||||||
includeOtherData: 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 {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
@@ -82,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) {
|
||||||
@@ -90,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,
|
||||||
};
|
};
|
||||||
@@ -188,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) {
|
||||||
@@ -265,28 +330,121 @@ export const validateImportData = (data: any): ImportValidationResult => {
|
|||||||
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);
|
||||||
isFed: dose.isFed, // Explicitly preserve food-timing flag
|
|
||||||
}))
|
// 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) {
|
||||||
@@ -373,18 +531,35 @@ export const deleteSelectedData = (
|
|||||||
let shouldRemoveMainStorage = false;
|
let shouldRemoveMainStorage = false;
|
||||||
|
|
||||||
if (options.includeSchedules) {
|
if (options.includeSchedules) {
|
||||||
// Delete schedules - but always keep template day with at least one dose
|
// Delete all profiles and optionally restore examples
|
||||||
// Never allow complete deletion as this breaks the app
|
|
||||||
const defaults = getDefaultState();
|
const defaults = getDefaultState();
|
||||||
newState.days = [
|
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',
|
id: 'day-template',
|
||||||
isTemplate: true,
|
isTemplate: true,
|
||||||
doses: [
|
doses: [
|
||||||
{ id: 'dose-default', time: '06:00', ldx: '70' }
|
{ 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;
|
shouldRemoveMainStorage = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +571,7 @@ export const deleteSelectedData = (
|
|||||||
newState.uiSettings.showDayTimeOnXAxis = defaults.uiSettings.showDayTimeOnXAxis;
|
newState.uiSettings.showDayTimeOnXAxis = defaults.uiSettings.showDayTimeOnXAxis;
|
||||||
newState.uiSettings.showTemplateDay = defaults.uiSettings.showTemplateDay;
|
newState.uiSettings.showTemplateDay = defaults.uiSettings.showTemplateDay;
|
||||||
newState.uiSettings.showDayReferenceLines = defaults.uiSettings.showDayReferenceLines;
|
newState.uiSettings.showDayReferenceLines = defaults.uiSettings.showDayReferenceLines;
|
||||||
|
newState.uiSettings.showIntakeTimeLines = defaults.uiSettings.showIntakeTimeLines;
|
||||||
newState.uiSettings.showTherapeuticRange = defaults.uiSettings.showTherapeuticRange;
|
newState.uiSettings.showTherapeuticRange = defaults.uiSettings.showTherapeuticRange;
|
||||||
newState.uiSettings.stickyChart = defaults.uiSettings.stickyChart;
|
newState.uiSettings.stickyChart = defaults.uiSettings.stickyChart;
|
||||||
shouldRemoveMainStorage = true;
|
shouldRemoveMainStorage = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user