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;
}