From b198164760707551d11e6f84b63529989ca2832f Mon Sep 17 00:00:00 2001 From: Andreas Weyer Date: Tue, 10 Feb 2026 18:33:41 +0000 Subject: [PATCH] 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. --- src/App.tsx | 38 +++- src/components/data-management-modal.tsx | 163 +++++++++++++-- src/components/profile-selector.tsx | 223 +++++++++++++++++++++ src/constants/defaults.ts | 144 ++++++++++---- src/hooks/useAppState.ts | 232 ++++++++++++++++++---- src/locales/de.ts | 65 ++++-- src/locales/en.ts | 69 ++++--- src/utils/exportImport.ts | 243 +++++++++++++++++++---- 8 files changed, 1000 insertions(+), 177 deletions(-) create mode 100644 src/components/profile-selector.tsx diff --git a/src/App.tsx b/src/App.tsx index 1b4c90b..fbf5ac5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import LanguageSelector from './components/language-selector'; import ThemeSelector from './components/theme-selector'; import DisclaimerModal from './components/disclaimer-modal'; import DataManagementModal from './components/data-management-modal'; +import { ProfileSelector } from './components/profile-selector'; import { Button } from './components/ui/button'; import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from './components/ui/tooltip'; import { IconButtonWithTooltip } from './components/ui/icon-button-with-tooltip'; @@ -75,12 +76,23 @@ const MedPlanAssistant = () => { removeDoseFromDay, updateDoseInDay, updateDoseFieldInDay, - sortDosesInDay + sortDosesInDay, + // Profile management + getActiveProfile, + createProfile, + deleteProfile, + switchProfile, + saveProfile, + saveProfileAs, + updateProfileName, + hasUnsavedChanges } = useAppState(); const { pkParams, days, + profiles, + activeProfileId, therapeuticRange, doseIncrement, uiSettings @@ -138,6 +150,10 @@ const MedPlanAssistant = () => { Object.entries(newState).forEach(([key, value]) => { if (key === 'days') { updateState('days', value as any); + } else if (key === 'profiles') { + updateState('profiles', value as any); + } else if (key === 'activeProfileId') { + updateState('activeProfileId', value as any); } else if (key === 'pkParams') { updateState('pkParams', value as any); } else if (key === 'therapeuticRange') { @@ -174,6 +190,8 @@ const MedPlanAssistant = () => { t={t} pkParams={pkParams} days={days} + profiles={profiles} + activeProfileId={activeProfileId} therapeuticRange={therapeuticRange} doseIncrement={doseIncrement} uiSettings={uiSettings} @@ -181,6 +199,14 @@ const MedPlanAssistant = () => { onUpdateTherapeuticRange={(key: any, value: any) => updateNestedState('therapeuticRange', key, value)} onUpdateUiSetting={(key: any, value: any) => updateUiSetting(key as any, value)} onImportDays={(importedDays: any) => updateState('days', importedDays)} + onImportProfiles={(importedProfiles: any, newActiveProfileId: string) => { + updateState('profiles', importedProfiles); + updateState('activeProfileId', newActiveProfileId); + const newActiveProfile = importedProfiles.find((p: any) => p.id === newActiveProfileId); + if (newActiveProfile) { + updateState('days', newActiveProfile.days); + } + }} onDeleteData={handleDeleteData} /> @@ -282,6 +308,16 @@ const MedPlanAssistant = () => { {/* Left Column - Controls */}
+ void; onUpdateUiSetting: (key: string, value: any) => void; onImportDays?: (days: any) => void; + onImportProfiles?: (profiles: any[], activeProfileId: string) => void; onDeleteData?: (options: ExportImportOptions) => void; } @@ -80,6 +87,8 @@ const DataManagementModal: React.FC = ({ t, pkParams, days, + profiles, + activeProfileId, therapeuticRange, doseIncrement, uiSettings, @@ -87,11 +96,13 @@ const DataManagementModal: React.FC = ({ onUpdateTherapeuticRange, onUpdateUiSetting, onImportDays, + onImportProfiles, onDeleteData, }) => { // Export/Import options const [exportOptions, setExportOptions] = useState({ includeSchedules: true, + exportAllProfiles: true, // Default to exporting all profiles includeDiagramSettings: true, includeSimulationSettings: true, includePharmacoSettings: true, @@ -108,14 +119,17 @@ const DataManagementModal: React.FC = ({ includeOtherData: false, }); + const [mergeProfiles, setMergeProfiles] = useState(false); + // Deletion options - defaults: all except otherData const [deletionOptions, setDeletionOptions] = useState({ - includeSchedules: true, - includeDiagramSettings: true, - includeSimulationSettings: true, - includePharmacoSettings: true, - includeAdvancedSettings: true, - includeOtherData: true, + includeSchedules: false, + restoreExamples: true, // Restore examples by default + includeDiagramSettings: false, + includeSimulationSettings: false, + includePharmacoSettings: false, + includeAdvancedSettings: false, + includeOtherData: false, }); // File upload state @@ -134,6 +148,16 @@ const DataManagementModal: React.FC = ({ // Clipboard feedback 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 React.useEffect(() => { // TODO nice to have: use can decide behavior via checkbox (near editor) @@ -158,6 +182,7 @@ const DataManagementModal: React.FC = ({ setJsonEditorContent(''); setJsonEditorExpanded(false); setJsonValidationMessage({ type: null, message: '' }); + setAvailableCategories(null); setSelectedFile(null); if (fileInputRef.current) { fileInputRef.current.value = ''; @@ -177,6 +202,8 @@ const DataManagementModal: React.FC = ({ const appState = { pkParams, days, + profiles: profiles || [], + activeProfileId: activeProfileId || '', therapeuticRange, doseIncrement, uiSettings, @@ -197,6 +224,8 @@ const DataManagementModal: React.FC = ({ const appState = { pkParams, days, + profiles: profiles || [], + activeProfileId: activeProfileId || '', therapeuticRange, doseIncrement, uiSettings, @@ -292,6 +321,7 @@ const DataManagementModal: React.FC = ({ const validateJsonContent = (content: string) => { if (!content.trim()) { setJsonValidationMessage({ type: null, message: '' }); + setAvailableCategories(null); return; } @@ -304,6 +334,7 @@ const DataManagementModal: React.FC = ({ type: 'error', message: t('importParseError'), }); + setAvailableCategories(null); return; } @@ -314,9 +345,21 @@ const DataManagementModal: React.FC = ({ type: 'error', message: validation.errors.join(', '), }); + setAvailableCategories(null); 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) { // Show success with warnings - warnings will be displayed separately setJsonValidationMessage({ @@ -336,6 +379,7 @@ const DataManagementModal: React.FC = ({ type: 'error', message: t('pasteInvalidJson'), }); + setAvailableCategories(null); } }; @@ -400,15 +444,26 @@ const DataManagementModal: React.FC = ({ const currentState = { pkParams, days, + profiles: profiles || [], + activeProfileId: activeProfileId || '', therapeuticRange, doseIncrement, uiSettings, steadyStateConfig: { daysOnMedication: pkParams.advanced.steadyStateDays }, }; - const newState = importSettings(currentState, importData.data, importOptions); - // Apply schedules - if (newState.days && importOptions.includeSchedules && onImportDays) { + const importOpts: ImportOptions = { + 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); } @@ -447,7 +502,11 @@ const DataManagementModal: React.FC = ({ onClose(); } catch (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 = ({ // Handle delete selected data 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) { alert(t('deleteNoOptionsSelected')); return; @@ -544,6 +611,33 @@ const DataManagementModal: React.FC = ({ {t('exportOptionSchedules')}
+ {exportOptions.includeSchedules && profiles && profiles.length > 1 && ( +
+ + setExportOptions({ ...exportOptions, exportAllProfiles: checked }) + } + /> + + + + + + +
{formatContent(t('exportAllProfilesTooltip'))}
+
+
+
+ )}
= ({ setImportOptions({ ...importOptions, includeSchedules: checked }) } @@ -674,10 +769,36 @@ const DataManagementModal: React.FC = ({ {t('exportOptionSchedules')}
+ {importOptions.includeSchedules && profiles && ( +
+ + + + + + + +
{formatContent(t('mergeProfilesTooltip'))}
+
+
+
+ )}
setImportOptions({ ...importOptions, includeDiagramSettings: checked }) } @@ -690,6 +811,7 @@ const DataManagementModal: React.FC = ({ setImportOptions({ ...importOptions, includeSimulationSettings: checked }) } @@ -702,6 +824,7 @@ const DataManagementModal: React.FC = ({ setImportOptions({ ...importOptions, includePharmacoSettings: checked }) } @@ -714,6 +837,7 @@ const DataManagementModal: React.FC = ({ setImportOptions({ ...importOptions, includeAdvancedSettings: checked }) } @@ -726,6 +850,7 @@ const DataManagementModal: React.FC = ({ setImportOptions({ ...importOptions, includeOtherData: checked }) } @@ -945,6 +1070,20 @@ const DataManagementModal: React.FC = ({ {t('exportOptionSchedules')}
+ {deletionOptions.includeSchedules && ( +
+ + setDeletionOptions({ ...deletionOptions, restoreExamples: checked }) + } + /> + +
+ )}
void; + onSaveProfile: () => void; + onSaveProfileAs: (name: string) => string | null; + onDeleteProfile: (profileId: string) => boolean; + t: (key: string) => string; +} + +export const ProfileSelector: React.FC = ({ + 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 ( + + +
+ {/* Title label */} + + + {/* Profile selector with integrated buttons */} +
+ {/* Profile selector / name input */} + {isSaveAsMode ? ( + setNewProfileName(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t('profileSaveAsPlaceholder')} + autoFocus + className="h-9 rounded-r-none border-r-0 w-[360px] bg-background" + /> + ) : ( + + )} + + {/* Save button - integrated */} + } + 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 */} + } + 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" + /> +
+ + {/* Helper text for save-as mode */} + {isSaveAsMode && ( +
+

+ {t('profileSaveAsHelp')} +

+ +
+ )} +
+
+
+ ); +}; diff --git a/src/constants/defaults.ts b/src/constants/defaults.ts index e8ed361..c088c31 100644 --- a/src/constants/defaults.ts +++ b/src/constants/defaults.ts @@ -26,7 +26,8 @@ const versionInfo = versionJsonDefault && Object.keys(versionJsonDefault).length 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 APP_VERSION = versionInfo.version; export const BUILD_INFO = versionInfo; @@ -80,6 +81,14 @@ export interface DayGroup { doses: DayDose[]; } +export interface ScheduleProfile { + id: string; + name: string; + days: DayGroup[]; + createdAt: string; + modifiedAt: string; +} + export interface SteadyStateConfig { daysOnMedication: string; } @@ -107,7 +116,9 @@ export interface UiSettings { export interface AppState { pkParams: PkParams; - days: DayGroup[]; + days: DayGroup[]; // Kept for backwards compatibility during migration + profiles: ScheduleProfile[]; + activeProfileId: string; steadyStateConfig: SteadyStateConfig; therapeuticRange: TherapeuticRange; doseIncrement: string; @@ -133,47 +144,94 @@ export interface ConcentrationPoint { } // Default application state -export const getDefaultState = (): AppState => ({ - pkParams: { - damph: { halfLife: '11' }, - ldx: { - 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: [ +export const getDefaultState = (): AppState => { + const now = new Date().toISOString(); + + const profiles: ScheduleProfile[] = [ { - id: 'day-template', - 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' }, + 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' } + ] + } ] } - ], - 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', - } -}); + ]; + + return { + pkParams: { + damph: { halfLife: '11' }, + ldx: { + 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: 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', + } + }; +}; diff --git a/src/hooks/useAppState.ts b/src/hooks/useAppState.ts index 609fd29..caad63b 100644 --- a/src/hooks/useAppState.ts +++ b/src/hooks/useAppState.ts @@ -10,7 +10,7 @@ */ 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 = () => { const [appState, setAppState] = React.useState(getDefaultState); @@ -94,51 +94,45 @@ export const useAppState = () => { return value; }; - // Validate basic pkParams - if (migratedPkParams.basic) { - migratedPkParams.basic.eliminationHalfLife = validateNumericField( - migratedPkParams.basic.eliminationHalfLife, - defaults.pkParams.basic.eliminationHalfLife - ); - migratedPkParams.basic.bodyWeight = validateNumericField( - migratedPkParams.basic.bodyWeight, - defaults.pkParams.basic.bodyWeight - ); - } + // Migrate from old days-only format to profile-based format + let migratedProfiles: ScheduleProfile[] = defaults.profiles; + let migratedActiveProfileId: string = defaults.activeProfileId; + let migratedDays: DayGroup[] = defaults.days; - // Validate advanced pkParams - if (migratedPkParams.advanced) { - migratedPkParams.advanced.conversionEfficiency = validateNumericField( - migratedPkParams.advanced.conversionEfficiency, - defaults.pkParams.advanced.conversionEfficiency - ); - migratedPkParams.advanced.bioavailability = validateNumericField( - migratedPkParams.advanced.bioavailability, - defaults.pkParams.advanced.bioavailability - ); - migratedPkParams.advanced.customVolumeOfDistribution = validateNumericField( - migratedPkParams.advanced.customVolumeOfDistribution, - defaults.pkParams.advanced.customVolumeOfDistribution - ); - 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 - ); + if (parsedState.profiles && Array.isArray(parsedState.profiles)) { + // New format with profiles + migratedProfiles = parsedState.profiles; + migratedActiveProfileId = parsedState.activeProfileId || parsedState.profiles[0]?.id || defaults.activeProfileId; + + // Validate activeProfileId exists in profiles + const activeProfile = migratedProfiles.find(p => p.id === migratedActiveProfileId); + if (!activeProfile && migratedProfiles.length > 0) { + migratedActiveProfileId = migratedProfiles[0].id; + } + + // Set days from active profile + migratedDays = activeProfile?.days || defaults.days; + } else if (parsedState.days) { + // Old format: migrate days to default profile + const now = new Date().toISOString(); + migratedProfiles = [{ + id: `profile-migrated-${Date.now()}`, + name: 'Default', + days: parsedState.days, + createdAt: now, + modifiedAt: now + }]; + migratedActiveProfileId = migratedProfiles[0].id; + migratedDays = parsedState.days; } setAppState({ ...defaults, ...parsedState, pkParams: migratedPkParams, - days: parsedState.days || defaults.days, + days: migratedDays, + profiles: migratedProfiles, + activeProfileId: migratedActiveProfileId, uiSettings: migratedUiSettings, }); } @@ -154,6 +148,8 @@ export const useAppState = () => { const stateToSave = { pkParams: appState.pkParams, days: appState.days, + profiles: appState.profiles, + activeProfileId: appState.activeProfileId, steadyStateConfig: appState.steadyStateConfig, therapeuticRange: appState.therapeuticRange, 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 { appState, isLoaded, @@ -377,6 +520,15 @@ export const useAppState = () => { removeDoseFromDay, updateDoseInDay, updateDoseFieldInDay, - sortDosesInDay + sortDosesInDay, + // Profile management + getActiveProfile, + createProfile, + deleteProfile, + switchProfile, + saveProfile, + saveProfileAs, + updateProfileName, + hasUnsavedChanges }; }; diff --git a/src/locales/de.ts b/src/locales/de.ts index d667da0..505ce44 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -10,7 +10,7 @@ export const de = { lisdexamfetamine: "Lisdexamfetamin", lisdexamfetamineShort: "LDX", both: "Beide", - regularPlanOverlayShort: "Reg.", + regularPlanOverlayShort: "Basis", // Language selector languageSelectorLabel: "Sprache", @@ -23,7 +23,7 @@ export const de = { themeSelectorSystem: "💻 System", // Dose Schedule - myPlan: "Mein Plan", + myPlan: "Mein Zeitplan", morning: "Morgens", midday: "Mittags", afternoon: "Nachmittags", @@ -32,8 +32,29 @@ export const de = { doseWithFood: "Mit Nahrung eingenommen (verzögert Absorption ~1h)", 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 - deviationsFromPlan: "Abweichungen vom Plan", + deviationsFromPlan: "Abweichungen vom Zeitplan", addDeviation: "Abweichung hinzufügen", day: "Tag", additional: "Zusätzlich", @@ -53,13 +74,13 @@ export const de = { axisLabelHours: "Stunden (h)", axisLabelTimeOfDay: "Tageszeit (h)", tickNoon: "Mittag", - refLineRegularPlan: "Regulär", - refLineNoDeviation: "Regulär", + refLineRegularPlan: "Basis", + refLineNoDeviation: "Basis", refLineRecovering: "Erholung", refLineIrregularIntake: "Irregulär", refLineDayX: "T{{x}}", - refLineRegularPlanShort: "(Reg.)", - refLineNoDeviationShort: "(Reg.)", + refLineRegularPlanShort: "(Basis)", + refLineNoDeviationShort: "(Basis)", refLineRecoveringShort: "(Erh.)", refLineIrregularIntakeShort: "(Irr.)", refLineDayShort: "T{{x}}", @@ -93,13 +114,13 @@ export const de = { xAxisFormat24hDesc: "Wiederholender 0-24h Zyklus", xAxisFormat12h: "Tageszeit (12h AM/PM)", xAxisFormat12hDesc: "Wiederholend 12h Zyklus im AM/PM Format", - showTemplateDayInChart: "Regulären Plan kontinuierlich anzeigen", - showTemplateDayTooltip: "Medikationsplan als Referenz-Overlay jederzeit anzeigen.\\n\\n__Standard:__ **aktiviert**", + showTemplateDayInChart: "Basis-Zeitplan kontinuierlich anzeigen", + 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", showDayReferenceLines: "Tagestrenner 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**", simulationDuration: "Simulationsdauer", 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", resetDiagramSettings: "Diagramm-Einstellungen zurücksetzen", resetPharmacokineticSettings: "Pharmakokinetik-Einstellungen zurücksetzen", - resetPlan: "Plan zurücksetzen", + resetPlan: "Zeitplan zurücksetzen", // Disclaimer Modal disclaimerModalTitle: "Wichtiger medizinischer Haftungsausschluss", @@ -213,7 +234,7 @@ export const de = { exportOptionPharmaco: "Pharmakokinetik-Einstellungen (Halbwertszeiten, therapeutischer Bereich)", exportOptionAdvanced: "Erweiterte Einstellungen (Gewicht, Nahrung, pH, Bioverfügbarkeit)", exportOptionOtherData: "Andere Daten (Design, eingeklappte Karten, Sprache, Haftungsausschluss)", - exportOptionOtherDataTooltip: "UI-Präferenzen wie Design, eingeklappte Kartenstatus, Spracheinstellung und Haftungsausschluss-Bestätigung. Normalerweise nicht nötig beim Teilen von 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", importButton: "Datei zum Importieren wählen", 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.*", // Day-based schedule - regularPlan: "Regulärer Plan", - deviatingPlan: "Abweichung vom Plan", - alternativePlan: "Alternativer Plan", - regularPlanOverlay: "Regulär", + regularPlan: "Basis-Zeitplan", + deviatingPlan: "Abweichung vom Zeitplan", + alternativePlan: "Alternativer Zeitplan", + regularPlanOverlay: "Basis", dayNumber: "Tag {{number}}", cloneDay: "Tag klonen", - addDay: "Tag hinzufügen", + addDay: "Tag hinzufügen (alternativer Zeitplan)", addDose: "Dosis hinzufügen", removeDose: "Dosis entfernen", removeDay: "Tag entfernen", @@ -314,17 +335,17 @@ export const de = { expandDay: "Tag ausklappen", dose: "Dosis", doses: "Dosen", - comparedToRegularPlan: "verglichen mit regulärem Plan", + comparedToRegularPlan: "verglichen mit Basis-Zeitplan", time: "Zeitpunkt der Einnahme", ldx: "LDX", damph: "d-amph", // URL sharing - sharePlan: "Plan teilen", - viewingSharedPlan: "Du siehst einen geteilten Plan", - saveAsMyPlan: "Als meinen Plan speichern", + sharePlan: "Zeitplan teilen", + viewingSharedPlan: "Du siehst einen geteilten Zeitplan", + saveAsMyPlan: "Als meinen Zeitplan speichern", discardSharedPlan: "Verwerfen", - planCopiedToClipboard: "Plan-Link in Zwischenablage kopiert!", + planCopiedToClipboard: "Zeitplan-Link in Zwischenablage kopiert!", // Time picker timePickerHour: "Stunde", diff --git a/src/locales/en.ts b/src/locales/en.ts index 1ca5d43..0f5213f 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -10,7 +10,7 @@ export const en = { lisdexamfetamine: "Lisdexamfetamine", lisdexamfetamineShort: "LDX", both: "Both", - regularPlanOverlayShort: "Reg.", + regularPlanOverlayShort: "Base", // Language selector languageSelectorLabel: "Language", @@ -23,7 +23,7 @@ export const en = { themeSelectorSystem: "💻 System", // Dose Schedule - myPlan: "My Plan", + myPlan: "My Schedule", morning: "Morning", midday: "Midday", afternoon: "Afternoon", @@ -32,12 +32,33 @@ export const en = { doseWithFood: "Taken with food (delays absorption ~1h)", 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 - deviationsFromPlan: "Deviations from Plan", + deviationsFromPlan: "Deviations from Schedule", addDeviation: "Add Deviation", day: "Day", 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 whatIf: "What if?", @@ -53,13 +74,13 @@ export const en = { axisLabelHours: "Hours (h)", axisLabelTimeOfDay: "Time of Day (h)", tickNoon: "Noon", - refLineRegularPlan: "Regular", - refLineNoDeviation: "Regular", + refLineRegularPlan: "Baseline", + refLineNoDeviation: "Baseline", refLineRecovering: "Recovering", refLineIrregularIntake: "Irregular", refLineDayX: "D{{x}}", - refLineRegularPlanShort: "(Reg.)", - refLineNoDeviationShort: "(Reg.)", // currently the same as above (day# > 1 with curve identical to day1 / regular plan) + refLineRegularPlanShort: "(Base)", + refLineNoDeviationShort: "(Base)", // currently the same as above (day# > 1 with curve identical to day1 / baseline schedule) refLineRecoveringShort: "(Rec.)", refLineIrregularIntakeShort: "(Ireg.)", refLineDayShort: "D{{x}}", @@ -92,12 +113,12 @@ export const en = { xAxisFormat24hDesc: "Repeating 0-24h cycle", xAxisFormat12h: "Time of Day (12h AM/PM)", xAxisFormat12hDesc: "Repeating 12h cycle in AM/PM format", - showTemplateDayInChart: "Continuously Show Regular Plan", - showTemplateDayTooltip: "Display the regular medication plan as reference overlay at all times.\\n\\n__Default:__ **enabled**", + showTemplateDayInChart: "Continuously Show Baseline Schedule", + 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", showDayReferenceLines: "Show Day Separators", 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**", simulationDuration: "Simulation Duration", 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", resetDiagramSettings: "Reset Diagram Settings", resetPharmacokineticSettings: "Reset Pharmacokinetic Settings", - resetPlan: "Reset Plan", + resetPlan: "Reset Schedule", // Disclaimer Modal disclaimerModalTitle: "Important Medical Disclaimer", @@ -205,13 +226,13 @@ export const en = { importSettings: "Import Settings", exportSelectWhat: "Select what to export:", importSelectWhat: "Select what to import:", - exportOptionSchedules: "Schedules (Day plans with doses)", + exportOptionSchedules: "Schedules (Daily plans with doses)", exportOptionDiagram: "Diagram Settings (View options, chart display)", exportOptionSimulation: "Simulation Settings (Duration, range, chart view)", exportOptionPharmaco: "Pharmacokinetic Settings (Half-lives, therapeutic range)", exportOptionAdvanced: "Advanced Settings (Weight, food, pH, bioavailability)", exportOptionOtherData: "Other Data (Theme, collapsed cards, language, disclaimer)", - exportOptionOtherDataTooltip: "UI preferences like theme, collapsed card states, language preference, and disclaimer acceptance. Typically not needed when sharing 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", importButton: "Choose File to Import", importApplyButton: "Apply Import", @@ -315,13 +336,13 @@ export const en = { sortByTimeSorted: "Doses are sorted chronologically.", // Day-based schedule - regularPlan: "Regular Plan", - deviatingPlan: "Deviation from Plan", - alternativePlan: "Alternative Plan", - regularPlanOverlay: "Regular", + regularPlan: "Baseline Schedule", + deviatingPlan: "Deviation from Schedule", + alternativePlan: "Alternative Schedule", + regularPlanOverlay: "Baseline", dayNumber: "Day {{number}}", cloneDay: "Clone day", - addDay: "Add day", + addDay: "Add day (alternative schedule)", addDose: "Add dose", removeDose: "Remove dose", removeDay: "Remove day", @@ -329,17 +350,17 @@ export const en = { expandDay: "Expand day", dose: "dose", doses: "doses", - comparedToRegularPlan: "compared to regular plan", + comparedToRegularPlan: "compared to baseline schedule", time: "Time of Intake", ldx: "LDX", damph: "d-amph", // URL sharing - sharePlan: "Share Plan", - viewingSharedPlan: "Viewing shared plan", - saveAsMyPlan: "Save as My Plan", + sharePlan: "Share Schedule", + viewingSharedPlan: "Viewing shared schedule", + saveAsMyPlan: "Save as My Schedule", discardSharedPlan: "Discard", - planCopiedToClipboard: "Plan link copied to clipboard!" + planCopiedToClipboard: "Schedule link copied to clipboard!" }; export default en; diff --git a/src/utils/exportImport.ts b/src/utils/exportImport.ts index f2cb567..9fc6cab 100644 --- a/src/utils/exportImport.ts +++ b/src/utils/exportImport.ts @@ -8,14 +8,15 @@ * @license MIT */ -import { AppState, getDefaultState } from '../constants/defaults'; +import { AppState, getDefaultState, MAX_PROFILES, type ScheduleProfile } from '../constants/defaults'; export interface ExportData { version: string; exportDate: string; appVersion: string; data: { - schedules?: AppState['days']; + schedules?: ScheduleProfile[]; // Schedule configurations (profile-based) + profiles?: ScheduleProfile[]; // Legacy: backward compatibility (renamed to schedules) diagramSettings?: { showDayTimeOnXAxis: AppState['uiSettings']['showDayTimeOnXAxis']; showTemplateDay: AppState['uiSettings']['showTemplateDay']; @@ -50,6 +51,8 @@ export interface ExportData { export interface ExportOptions { 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; includeSimulationSettings: boolean; includePharmacoSettings: boolean; @@ -57,6 +60,10 @@ export interface ExportOptions { includeOtherData: boolean; } +export interface ImportOptions { + mergeProfiles?: boolean; // If true, merge imported profiles with existing; if false, replace all +} + export interface ImportValidationResult { isValid: boolean; warnings: string[]; @@ -83,7 +90,26 @@ export const exportSettings = ( }; 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) { @@ -190,23 +216,60 @@ export const validateImportData = (data: any): ImportValidationResult => { const importData = data.data; - // Validate schedules + // Validate schedules (current profile-based format) if (importData.schedules !== undefined) { if (!Array.isArray(importData.schedules)) { result.errors.push('Schedules: Invalid format (expected array)'); result.isValid = false; } else { - // Check for required fields in schedules - importData.schedules.forEach((day: any, index: number) => { - if (!day.id || !Array.isArray(day.doses)) { - result.warnings.push(`Schedule day ${index + 1}: Missing required fields`); + // Check for required fields in schedule profiles + 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; } - day.doses?.forEach((dose: any, doseIndex: number) => { - if (!dose.id || dose.time === undefined || dose.ldx === undefined) { - result.warnings.push(`Schedule day ${index + 1}, dose ${doseIndex + 1}: Missing required fields`); + // Validate days within schedule + profile.days?.forEach((day: any, dayIndex: number) => { + if (!day.id || !Array.isArray(day.doses)) { + result.warnings.push(`Schedule ${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(`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; }; +/** + * 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 */ export const importSettings = ( currentState: AppState, importData: ExportData['data'], - options: ExportOptions + options: ExportOptions, + importOptions: ImportOptions = {} ): Partial => { const newState: Partial = {}; - if (options.includeSchedules && importData.schedules) { - newState.days = importData.schedules.map(day => ({ - ...day, - // Ensure all required fields exist - doses: day.doses.map(dose => ({ - id: dose.id || `dose-${Date.now()}-${Math.random()}`, - time: dose.time || '12:00', - ldx: dose.ldx || '0', - damph: dose.damph, - isFed: dose.isFed, // Explicitly preserve food-timing flag - })) - })); + if (options.includeSchedules) { + // Handle schedules (current profile-based format) + if (importData.schedules && importData.schedules.length > 0) { + const mergeMode = importOptions.mergeProfiles ?? false; + + if (mergeMode) { + // Merge: add imported schedules to existing ones + const existingProfiles = currentState.profiles || []; + const existingNames = existingProfiles.map(p => p.name); + + // Check if merge would exceed maximum schedules + if (existingProfiles.length + importData.schedules.length > MAX_PROFILES) { + throw new Error(`Cannot merge: Would exceed maximum of ${MAX_PROFILES} schedules. Please delete some schedules first.`); + } + + // Process imported schedules + const now = new Date().toISOString(); + const newProfiles = importData.schedules.map(profile => { + // Resolve name conflicts + const resolvedName = resolveProfileNameConflict(profile.name, existingNames); + existingNames.push(resolvedName); // Track for next iteration + + return { + ...profile, + id: `profile-import-${Date.now()}-${Math.random()}`, // New ID + name: resolvedName, + modifiedAt: now + }; + }); + + newState.profiles = [...existingProfiles, ...newProfiles]; + // Keep active profile unchanged + newState.activeProfileId = currentState.activeProfileId; + } else { + // Replace: overwrite all schedules + const now = new Date().toISOString(); + newState.profiles = importData.schedules.map((profile, index) => ({ + ...profile, + id: `profile-import-${Date.now()}-${index}`, // Regenerate IDs + modifiedAt: now + })); + + // Set first imported schedule as active + newState.activeProfileId = newState.profiles[0].id; + newState.days = newState.profiles[0].days; + } + } + // Handle legacy 'profiles' key (backward compatibility - renamed to schedules) + else if (importData.profiles && importData.profiles.length > 0) { + // Same logic as above but with legacy key + const mergeMode = importOptions.mergeProfiles ?? false; + + if (mergeMode) { + const existingProfiles = currentState.profiles || []; + const existingNames = existingProfiles.map(p => p.name); + + if (existingProfiles.length + importData.profiles.length > MAX_PROFILES) { + throw new Error(`Cannot merge: Would exceed maximum of ${MAX_PROFILES} schedules.`); + } + + const now = new Date().toISOString(); + const newProfiles = importData.profiles.map(profile => { + const resolvedName = resolveProfileNameConflict(profile.name, existingNames); + existingNames.push(resolvedName); + + return { + ...profile, + id: `profile-import-${Date.now()}-${Math.random()}`, + name: resolvedName, + modifiedAt: now + }; + }); + + newState.profiles = [...existingProfiles, ...newProfiles]; + newState.activeProfileId = currentState.activeProfileId; + } else { + const now = new Date().toISOString(); + newState.profiles = importData.profiles.map((profile, index) => ({ + ...profile, + id: `profile-import-${Date.now()}-${index}`, + modifiedAt: now + })); + + newState.activeProfileId = newState.profiles[0].id; + newState.days = newState.profiles[0].days; + } + } } if (options.includeDiagramSettings && importData.diagramSettings) { @@ -375,18 +531,35 @@ export const deleteSelectedData = ( let shouldRemoveMainStorage = false; if (options.includeSchedules) { - // Delete schedules - but always keep template day with at least one dose - // Never allow complete deletion as this breaks the app + // Delete all profiles and optionally restore examples const defaults = getDefaultState(); - newState.days = [ - { - id: 'day-template', - isTemplate: true, - doses: [ - { id: 'dose-default', time: '06:00', ldx: '70' } - ] - } - ]; + const now = new Date().toISOString(); + + if (options.restoreExamples) { + // Restore factory default example profiles + newState.profiles = defaults.profiles; + newState.activeProfileId = defaults.activeProfileId; + newState.days = defaults.days; + } else { + // Create a single blank profile + newState.profiles = [{ + id: `profile-blank-${Date.now()}`, + name: 'Default', + days: [ + { + id: 'day-template', + isTemplate: true, + doses: [ + { id: 'dose-default', time: '08:00', ldx: '30' } + ] + } + ], + createdAt: now, + modifiedAt: now + }]; + newState.activeProfileId = newState.profiles[0].id; + newState.days = newState.profiles[0].days; + } shouldRemoveMainStorage = true; }