Add profile management functionality
- Added profile management functions: createProfile, deleteProfile, switchProfile, saveProfile, saveProfileAs, updateProfileName, and hasUnsavedChanges. - Migrated state management to support profile-based format for schedules. - Updated localizations for profile management features in English and German. - Introduced ProfileSelector component for user interface to manage profiles. - Enhanced export/import functionality to handle profiles and schedules.
This commit is contained in:
38
src/App.tsx
38
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';
|
||||||
@@ -75,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
|
||||||
@@ -138,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') {
|
||||||
@@ -174,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}
|
||||||
@@ -181,6 +199,14 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -282,6 +308,16 @@ const MedPlanAssistant = () => {
|
|||||||
|
|
||||||
{/* Left Column - Controls */}
|
{/* Left Column - Controls */}
|
||||||
<div className="lg: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}
|
||||||
|
onDeleteProfile={deleteProfile}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
<DaySchedule
|
<DaySchedule
|
||||||
days={days}
|
days={days}
|
||||||
doseIncrement={doseIncrement}
|
doseIncrement={doseIncrement}
|
||||||
|
|||||||
@@ -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,7 +502,11 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
|
|||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Import error:', error);
|
console.error('Import error:', error);
|
||||||
alert(t('importError'));
|
if (error instanceof Error && error.message.includes('exceed maximum')) {
|
||||||
|
alert(error.message);
|
||||||
|
} else {
|
||||||
|
alert(t('importError'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
223
src/components/profile-selector.tsx
Normal file
223
src/components/profile-selector.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* 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 } 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;
|
||||||
|
onDeleteProfile: (profileId: string) => boolean;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileSelector: React.FC<ProfileSelectorProps> = ({
|
||||||
|
profiles,
|
||||||
|
activeProfileId,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
onSwitchProfile,
|
||||||
|
onSaveProfile,
|
||||||
|
onSaveProfileAs,
|
||||||
|
onDeleteProfile,
|
||||||
|
t,
|
||||||
|
}) => {
|
||||||
|
const [newProfileName, setNewProfileName] = useState('');
|
||||||
|
const [isSaveAsMode, setIsSaveAsMode] = useState(false);
|
||||||
|
|
||||||
|
const activeProfile = profiles.find(p => p.id === activeProfileId);
|
||||||
|
const canDelete = profiles.length > 1;
|
||||||
|
const canCreateNew = profiles.length < MAX_PROFILES;
|
||||||
|
|
||||||
|
const handleSelectChange = (value: string) => {
|
||||||
|
if (value === '__new__') {
|
||||||
|
// Enter "save as" mode
|
||||||
|
setIsSaveAsMode(true);
|
||||||
|
setNewProfileName('');
|
||||||
|
} else {
|
||||||
|
// Confirm before switching if there are unsaved changes
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
if (!window.confirm(t('profileSwitchUnsavedConfirm'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSwitchProfile(value);
|
||||||
|
setIsSaveAsMode(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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-[360px] bg-background"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={activeProfileId}
|
||||||
|
onValueChange={handleSelectChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="profile-selector" className="h-9 rounded-r-none border-r-0 w-[360px] bg-background">
|
||||||
|
<SelectValue>
|
||||||
|
{activeProfile?.name}
|
||||||
|
{hasUnsavedChanges && ' *'}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{profiles.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 : onSaveProfile}
|
||||||
|
icon={<Save className="h-4 w-4" />}
|
||||||
|
tooltip={isSaveAsMode ? t('profileSaveAs') : t('profileSave')}
|
||||||
|
disabled={(isSaveAsMode && !newProfileName.trim()) || (!isSaveAsMode && !hasUnsavedChanges)}
|
||||||
|
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}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -26,7 +26,8 @@ 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;
|
||||||
@@ -80,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;
|
||||||
}
|
}
|
||||||
@@ -107,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;
|
||||||
@@ -133,47 +144,94 @@ export interface ConcentrationPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default application state
|
// Default application state
|
||||||
export const getDefaultState = (): AppState => ({
|
export const getDefaultState = (): AppState => {
|
||||||
pkParams: {
|
const now = new Date().toISOString();
|
||||||
damph: { halfLife: '11' },
|
|
||||||
ldx: {
|
const profiles: ScheduleProfile[] = [
|
||||||
halfLife: '0.8',
|
|
||||||
absorptionHalfLife: '0.7' // Updated from 0.9 for better ~1h Tmax of prodrug
|
|
||||||
},
|
|
||||||
advanced: {
|
|
||||||
standardVd: { preset: 'adult', customValue: '377', bodyWeight: '70' }, // Adult: 377L (Roberts 2015), Child: ~150-200L, Weight-based: ~5.4 L/kg
|
|
||||||
foodEffect: { enabled: false, tmaxDelay: '1.0' }, // hours delay
|
|
||||||
urinePh: { mode: 'normal' }, // 'normal' (6-7.5), 'acidic' (<6), 'alkaline' (>7.5)
|
|
||||||
fOral: String(DEFAULT_F_ORAL), // 0.96 bioavailability
|
|
||||||
steadyStateDays: '7' // days of prior medication history
|
|
||||||
}
|
|
||||||
},
|
|
||||||
days: [
|
|
||||||
{
|
{
|
||||||
id: 'day-template',
|
id: 'profile-default-1',
|
||||||
isTemplate: true,
|
name: 'Single Morning Dose',
|
||||||
doses: [
|
createdAt: now,
|
||||||
{ id: 'dose-1', time: '06:30', ldx: '25' },
|
modifiedAt: now,
|
||||||
{ id: 'dose-2', time: '14:30', ldx: '15' },
|
days: [
|
||||||
{ id: 'dose-4', time: '22:15', ldx: '15' },
|
{
|
||||||
|
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' }
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
];
|
||||||
steadyStateConfig: { daysOnMedication: '7' }, // kept for backwards compatibility, now sourced from pkParams.advanced
|
|
||||||
therapeuticRange: { min: '', max: '' }, // users should personalize based on their response
|
return {
|
||||||
doseIncrement: '2.5',
|
pkParams: {
|
||||||
uiSettings: {
|
damph: { halfLife: '11' },
|
||||||
showDayTimeOnXAxis: '24h',
|
ldx: {
|
||||||
showTemplateDay: true,
|
halfLife: '0.8',
|
||||||
chartView: 'both',
|
absorptionHalfLife: '0.7' // Updated from 0.9 for better ~1h Tmax of prodrug
|
||||||
yAxisMin: '',
|
},
|
||||||
yAxisMax: '',
|
advanced: {
|
||||||
simulationDays: '5',
|
standardVd: { preset: 'adult', customValue: '377', bodyWeight: '70' }, // Adult: 377L (Roberts 2015), Child: ~150-200L, Weight-based: ~5.4 L/kg
|
||||||
displayedDays: '2',
|
foodEffect: { enabled: false, tmaxDelay: '1.0' }, // hours delay
|
||||||
showTherapeuticRange: false,
|
urinePh: { mode: 'normal' }, // 'normal' (6-7.5), 'acidic' (<6), 'alkaline' (>7.5)
|
||||||
showIntakeTimeLines: false,
|
fOral: String(DEFAULT_F_ORAL), // 0.96 bioavailability
|
||||||
steadyStateDaysEnabled: true,
|
steadyStateDays: '7' // days of prior medication history
|
||||||
stickyChart: false,
|
}
|
||||||
theme: 'system',
|
},
|
||||||
}
|
days: profiles[0].days, // For backwards compatibility, use first profile's days
|
||||||
});
|
profiles,
|
||||||
|
activeProfileId: profiles[0].id,
|
||||||
|
steadyStateConfig: { daysOnMedication: '7' }, // kept for backwards compatibility, now sourced from pkParams.advanced
|
||||||
|
therapeuticRange: { min: '', max: '' }, // users should personalize based on their response
|
||||||
|
doseIncrement: '2.5',
|
||||||
|
uiSettings: {
|
||||||
|
showDayTimeOnXAxis: '24h',
|
||||||
|
showTemplateDay: true,
|
||||||
|
chartView: 'both',
|
||||||
|
yAxisMin: '',
|
||||||
|
yAxisMax: '',
|
||||||
|
simulationDays: '5',
|
||||||
|
displayedDays: '2',
|
||||||
|
showTherapeuticRange: false,
|
||||||
|
showIntakeTimeLines: false,
|
||||||
|
steadyStateDaysEnabled: true,
|
||||||
|
stickyChart: false,
|
||||||
|
theme: 'system',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { LOCAL_STORAGE_KEY, getDefaultState, MAX_DOSES_PER_DAY, 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
|
|
||||||
);
|
|
||||||
migratedPkParams.basic.bodyWeight = validateNumericField(
|
|
||||||
migratedPkParams.basic.bodyWeight,
|
|
||||||
defaults.pkParams.basic.bodyWeight
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate advanced pkParams
|
if (parsedState.profiles && Array.isArray(parsedState.profiles)) {
|
||||||
if (migratedPkParams.advanced) {
|
// New format with profiles
|
||||||
migratedPkParams.advanced.conversionEfficiency = validateNumericField(
|
migratedProfiles = parsedState.profiles;
|
||||||
migratedPkParams.advanced.conversionEfficiency,
|
migratedActiveProfileId = parsedState.activeProfileId || parsedState.profiles[0]?.id || defaults.activeProfileId;
|
||||||
defaults.pkParams.advanced.conversionEfficiency
|
|
||||||
);
|
// Validate activeProfileId exists in profiles
|
||||||
migratedPkParams.advanced.bioavailability = validateNumericField(
|
const activeProfile = migratedProfiles.find(p => p.id === migratedActiveProfileId);
|
||||||
migratedPkParams.advanced.bioavailability,
|
if (!activeProfile && migratedProfiles.length > 0) {
|
||||||
defaults.pkParams.advanced.bioavailability
|
migratedActiveProfileId = migratedProfiles[0].id;
|
||||||
);
|
}
|
||||||
migratedPkParams.advanced.customVolumeOfDistribution = validateNumericField(
|
|
||||||
migratedPkParams.advanced.customVolumeOfDistribution,
|
// Set days from active profile
|
||||||
defaults.pkParams.advanced.customVolumeOfDistribution
|
migratedDays = activeProfile?.days || defaults.days;
|
||||||
);
|
} else if (parsedState.days) {
|
||||||
migratedPkParams.advanced.absorptionDelay = validateNumericField(
|
// Old format: migrate days to default profile
|
||||||
migratedPkParams.advanced.absorptionDelay,
|
const now = new Date().toISOString();
|
||||||
defaults.pkParams.advanced.absorptionDelay
|
migratedProfiles = [{
|
||||||
);
|
id: `profile-migrated-${Date.now()}`,
|
||||||
migratedPkParams.advanced.absorptionRateConstant = validateNumericField(
|
name: 'Default',
|
||||||
migratedPkParams.advanced.absorptionRateConstant,
|
days: parsedState.days,
|
||||||
defaults.pkParams.advanced.absorptionRateConstant
|
createdAt: now,
|
||||||
);
|
modifiedAt: now
|
||||||
migratedPkParams.advanced.mealDelayFactor = validateNumericField(
|
}];
|
||||||
migratedPkParams.advanced.mealDelayFactor,
|
migratedActiveProfileId = migratedProfiles[0].id;
|
||||||
defaults.pkParams.advanced.mealDelayFactor
|
migratedDays = parsedState.days;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -364,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,
|
||||||
@@ -377,6 +520,15 @@ export const useAppState = () => {
|
|||||||
removeDoseFromDay,
|
removeDoseFromDay,
|
||||||
updateDoseInDay,
|
updateDoseInDay,
|
||||||
updateDoseFieldInDay,
|
updateDoseFieldInDay,
|
||||||
sortDosesInDay
|
sortDosesInDay,
|
||||||
|
// Profile management
|
||||||
|
getActiveProfile,
|
||||||
|
createProfile,
|
||||||
|
deleteProfile,
|
||||||
|
switchProfile,
|
||||||
|
saveProfile,
|
||||||
|
saveProfileAs,
|
||||||
|
updateProfileName,
|
||||||
|
hasUnsavedChanges
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,29 @@ 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",
|
||||||
|
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",
|
||||||
|
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 +74,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}}",
|
||||||
@@ -93,13 +114,13 @@ 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 kontinuierlich anzeigen",
|
||||||
showTemplateDayTooltip: "Medikationsplan als Referenz-Overlay jederzeit anzeigen.\\n\\n__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.\\n\\n__Standard:__ **aktiviert**", showIntakeTimeLines: "Einnahmezeitmarkierungen anzeigen",
|
showDayReferenceLinesTooltip: "Vertikale Linien und Statusanzeigen zwischen Tagen anzeigen.\\n\\n__Standard:__ **aktiviert**", showIntakeTimeLines: "Einnahmezeitmarkierungen anzeigen",
|
||||||
showIntakeTimeLinesTooltip: "Vertikale gestrichelte Linien an Einnahmezeiten mit Dosis-Index-Labels anzeigen.\n\n__Standard:__ **deaktiviert**", 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.\\n\\n__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.\\n\\n__Standard:__ **{{simulationDays}} Tage**",
|
simulationDurationTooltip: "Anzahl der zu simulierenden Tage. Längere Zeiträume zeigen Steady-State.\\n\\n__Standard:__ **{{simulationDays}} Tage**",
|
||||||
@@ -171,7 +192,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 +234,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",
|
||||||
@@ -300,13 +321,13 @@ export const de = {
|
|||||||
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.*",
|
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",
|
||||||
@@ -314,17 +335,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: "Zeitpunkt der Einnahme",
|
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,33 @@ 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",
|
||||||
|
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",
|
||||||
|
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 +74,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}}",
|
||||||
@@ -92,12 +113,12 @@ 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: "Continuously Show Baseline Schedule",
|
||||||
showTemplateDayTooltip: "Display the regular medication plan as reference overlay at all times.\\n\\n__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.\\n\\n__Default:__ **enabled**", showIntakeTimeLines: "Show Intake Time Markers",
|
showDayReferenceLinesTooltip: "Display vertical lines and status indicators separating days.\\n\\n__Default:__ **enabled**", showIntakeTimeLines: "Show Intake Time Markers",
|
||||||
showIntakeTimeLinesTooltip: "Display vertical dashed lines at intake times with dose index labels.\n\n__Default:__ **disabled**", 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.\\n\\n__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.\\n\\n__Default:__ **{{simulationDays}} days**",
|
simulationDurationTooltip: "Number of days to simulate. Longer periods allow steady-state observation.\\n\\n__Default:__ **{{simulationDays}} days**",
|
||||||
@@ -169,7 +190,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 +226,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",
|
||||||
@@ -315,13 +336,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",
|
||||||
@@ -329,17 +350,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 of Intake",
|
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;
|
||||||
|
|||||||
@@ -8,14 +8,15 @@
|
|||||||
* @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'];
|
||||||
@@ -50,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;
|
||||||
@@ -57,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[];
|
||||||
@@ -83,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) {
|
||||||
@@ -190,23 +216,60 @@ 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 (!day.id || !Array.isArray(day.doses)) {
|
if (!profile.id || !profile.name || !Array.isArray(profile.days)) {
|
||||||
result.warnings.push(`Schedule day ${index + 1}: Missing required fields`);
|
result.warnings.push(`Schedule ${index + 1}: Missing required fields (id, name, or days)`);
|
||||||
result.hasMissingFields = true;
|
result.hasMissingFields = true;
|
||||||
}
|
}
|
||||||
day.doses?.forEach((dose: any, doseIndex: number) => {
|
// Validate days within schedule
|
||||||
if (!dose.id || dose.time === undefined || dose.ldx === undefined) {
|
profile.days?.forEach((day: any, dayIndex: number) => {
|
||||||
result.warnings.push(`Schedule day ${index + 1}, dose ${doseIndex + 1}: Missing required fields`);
|
if (!day.id || !Array.isArray(day.doses)) {
|
||||||
|
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) => {
|
||||||
|
if (!dose.id || dose.time === undefined || dose.ldx === undefined) {
|
||||||
|
result.warnings.push(`Schedule ${index + 1}, day ${dayIndex + 1}, dose ${doseIndex + 1}: Missing required fields`);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -267,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) {
|
||||||
@@ -375,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();
|
||||||
{
|
|
||||||
id: 'day-template',
|
if (options.restoreExamples) {
|
||||||
isTemplate: true,
|
// Restore factory default example profiles
|
||||||
doses: [
|
newState.profiles = defaults.profiles;
|
||||||
{ id: 'dose-default', time: '06:00', ldx: '70' }
|
newState.activeProfileId = defaults.activeProfileId;
|
||||||
]
|
newState.days = defaults.days;
|
||||||
}
|
} else {
|
||||||
];
|
// Create a single blank profile
|
||||||
|
newState.profiles = [{
|
||||||
|
id: `profile-blank-${Date.now()}`,
|
||||||
|
name: 'Default',
|
||||||
|
days: [
|
||||||
|
{
|
||||||
|
id: 'day-template',
|
||||||
|
isTemplate: true,
|
||||||
|
doses: [
|
||||||
|
{ id: 'dose-default', time: '08:00', ldx: '30' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createdAt: now,
|
||||||
|
modifiedAt: now
|
||||||
|
}];
|
||||||
|
newState.activeProfileId = newState.profiles[0].id;
|
||||||
|
newState.days = newState.profiles[0].days;
|
||||||
|
}
|
||||||
shouldRemoveMainStorage = true;
|
shouldRemoveMainStorage = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user