Add profile management functionality

- Added profile management functions: createProfile, deleteProfile, switchProfile, saveProfile, saveProfileAs, updateProfileName, and hasUnsavedChanges.
- Migrated state management to support profile-based format for schedules.
- Updated localizations for profile management features in English and German.
- Introduced ProfileSelector component for user interface to manage profiles.
- Enhanced export/import functionality to handle profiles and schedules.
This commit is contained in:
2026-02-10 18:33:41 +00:00
parent 3b4db14424
commit b198164760
8 changed files with 1000 additions and 177 deletions

View File

@@ -20,6 +20,7 @@ import LanguageSelector from './components/language-selector';
import ThemeSelector from './components/theme-selector'; import ThemeSelector from './components/theme-selector';
import DisclaimerModal from './components/disclaimer-modal'; import DisclaimerModal from './components/disclaimer-modal';
import DataManagementModal from './components/data-management-modal'; import DataManagementModal from './components/data-management-modal';
import { ProfileSelector } from './components/profile-selector';
import { Button } from './components/ui/button'; import { Button } from './components/ui/button';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from './components/ui/tooltip'; import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from './components/ui/tooltip';
import { IconButtonWithTooltip } from './components/ui/icon-button-with-tooltip'; import { IconButtonWithTooltip } from './components/ui/icon-button-with-tooltip';
@@ -75,12 +76,23 @@ const MedPlanAssistant = () => {
removeDoseFromDay, removeDoseFromDay,
updateDoseInDay, updateDoseInDay,
updateDoseFieldInDay, updateDoseFieldInDay,
sortDosesInDay sortDosesInDay,
// Profile management
getActiveProfile,
createProfile,
deleteProfile,
switchProfile,
saveProfile,
saveProfileAs,
updateProfileName,
hasUnsavedChanges
} = useAppState(); } = useAppState();
const { const {
pkParams, pkParams,
days, days,
profiles,
activeProfileId,
therapeuticRange, therapeuticRange,
doseIncrement, doseIncrement,
uiSettings uiSettings
@@ -138,6 +150,10 @@ const MedPlanAssistant = () => {
Object.entries(newState).forEach(([key, value]) => { Object.entries(newState).forEach(([key, value]) => {
if (key === 'days') { if (key === 'days') {
updateState('days', value as any); updateState('days', value as any);
} else if (key === 'profiles') {
updateState('profiles', value as any);
} else if (key === 'activeProfileId') {
updateState('activeProfileId', value as any);
} else if (key === 'pkParams') { } else if (key === 'pkParams') {
updateState('pkParams', value as any); updateState('pkParams', value as any);
} else if (key === 'therapeuticRange') { } else if (key === 'therapeuticRange') {
@@ -174,6 +190,8 @@ const MedPlanAssistant = () => {
t={t} t={t}
pkParams={pkParams} pkParams={pkParams}
days={days} days={days}
profiles={profiles}
activeProfileId={activeProfileId}
therapeuticRange={therapeuticRange} therapeuticRange={therapeuticRange}
doseIncrement={doseIncrement} doseIncrement={doseIncrement}
uiSettings={uiSettings} uiSettings={uiSettings}
@@ -181,6 +199,14 @@ const MedPlanAssistant = () => {
onUpdateTherapeuticRange={(key: any, value: any) => updateNestedState('therapeuticRange', key, value)} onUpdateTherapeuticRange={(key: any, value: any) => updateNestedState('therapeuticRange', key, value)}
onUpdateUiSetting={(key: any, value: any) => updateUiSetting(key as any, value)} onUpdateUiSetting={(key: any, value: any) => updateUiSetting(key as any, value)}
onImportDays={(importedDays: any) => updateState('days', importedDays)} onImportDays={(importedDays: any) => updateState('days', importedDays)}
onImportProfiles={(importedProfiles: any, newActiveProfileId: string) => {
updateState('profiles', importedProfiles);
updateState('activeProfileId', newActiveProfileId);
const newActiveProfile = importedProfiles.find((p: any) => p.id === newActiveProfileId);
if (newActiveProfile) {
updateState('days', newActiveProfile.days);
}
}}
onDeleteData={handleDeleteData} onDeleteData={handleDeleteData}
/> />
@@ -282,6 +308,16 @@ const MedPlanAssistant = () => {
{/* Left Column - Controls */} {/* Left Column - Controls */}
<div className="lg:col-span-1 space-y-6"> <div className="lg:col-span-1 space-y-6">
<ProfileSelector
profiles={profiles}
activeProfileId={activeProfileId}
hasUnsavedChanges={hasUnsavedChanges()}
onSwitchProfile={switchProfile}
onSaveProfile={saveProfile}
onSaveProfileAs={saveProfileAs}
onDeleteProfile={deleteProfile}
t={t}
/>
<DaySchedule <DaySchedule
days={days} days={days}
doseIncrement={doseIncrement} doseIncrement={doseIncrement}

View File

@@ -44,11 +44,15 @@ import {
parseImportFile, parseImportFile,
validateImportData, validateImportData,
importSettings, importSettings,
type ImportOptions,
} from '../utils/exportImport'; } from '../utils/exportImport';
import { APP_VERSION } from '../constants/defaults'; import { formatContent } from '../utils/contentFormatter';
import { APP_VERSION, MAX_PROFILES } from '../constants/defaults';
interface ExportImportOptions { interface ExportImportOptions {
includeSchedules: boolean; includeSchedules: boolean;
exportAllProfiles?: boolean;
restoreExamples?: boolean;
includeDiagramSettings: boolean; includeDiagramSettings: boolean;
includeSimulationSettings: boolean; includeSimulationSettings: boolean;
includePharmacoSettings: boolean; includePharmacoSettings: boolean;
@@ -63,6 +67,8 @@ interface DataManagementModalProps {
// App state // App state
pkParams: any; pkParams: any;
days: any; days: any;
profiles?: any[];
activeProfileId?: string;
therapeuticRange: any; therapeuticRange: any;
doseIncrement: any; doseIncrement: any;
uiSettings: any; uiSettings: any;
@@ -71,6 +77,7 @@ interface DataManagementModalProps {
onUpdateTherapeuticRange: (key: string, value: any) => void; onUpdateTherapeuticRange: (key: string, value: any) => void;
onUpdateUiSetting: (key: string, value: any) => void; onUpdateUiSetting: (key: string, value: any) => void;
onImportDays?: (days: any) => void; onImportDays?: (days: any) => void;
onImportProfiles?: (profiles: any[], activeProfileId: string) => void;
onDeleteData?: (options: ExportImportOptions) => void; onDeleteData?: (options: ExportImportOptions) => void;
} }
@@ -80,6 +87,8 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
t, t,
pkParams, pkParams,
days, days,
profiles,
activeProfileId,
therapeuticRange, therapeuticRange,
doseIncrement, doseIncrement,
uiSettings, uiSettings,
@@ -87,11 +96,13 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
onUpdateTherapeuticRange, onUpdateTherapeuticRange,
onUpdateUiSetting, onUpdateUiSetting,
onImportDays, onImportDays,
onImportProfiles,
onDeleteData, onDeleteData,
}) => { }) => {
// Export/Import options // Export/Import options
const [exportOptions, setExportOptions] = useState<ExportImportOptions>({ const [exportOptions, setExportOptions] = useState<ExportImportOptions>({
includeSchedules: true, includeSchedules: true,
exportAllProfiles: true, // Default to exporting all profiles
includeDiagramSettings: true, includeDiagramSettings: true,
includeSimulationSettings: true, includeSimulationSettings: true,
includePharmacoSettings: true, includePharmacoSettings: true,
@@ -108,14 +119,17 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
includeOtherData: false, includeOtherData: false,
}); });
const [mergeProfiles, setMergeProfiles] = useState(false);
// Deletion options - defaults: all except otherData // Deletion options - defaults: all except otherData
const [deletionOptions, setDeletionOptions] = useState<ExportImportOptions>({ const [deletionOptions, setDeletionOptions] = useState<ExportImportOptions>({
includeSchedules: true, includeSchedules: false,
includeDiagramSettings: true, restoreExamples: true, // Restore examples by default
includeSimulationSettings: true, includeDiagramSettings: false,
includePharmacoSettings: true, includeSimulationSettings: false,
includeAdvancedSettings: true, includePharmacoSettings: false,
includeOtherData: true, includeAdvancedSettings: false,
includeOtherData: false,
}); });
// File upload state // File upload state
@@ -134,6 +148,16 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
// Clipboard feedback // Clipboard feedback
const [copySuccess, setCopySuccess] = useState(false); const [copySuccess, setCopySuccess] = useState(false);
// Track which categories are available in the loaded JSON
const [availableCategories, setAvailableCategories] = useState<{
schedules: boolean;
diagramSettings: boolean;
simulationSettings: boolean;
pharmacoSettings: boolean;
advancedSettings: boolean;
otherData: boolean;
} | null>(null);
// Reset editor when modal opens/closes // Reset editor when modal opens/closes
React.useEffect(() => { React.useEffect(() => {
// TODO nice to have: use can decide behavior via checkbox (near editor) // TODO nice to have: use can decide behavior via checkbox (near editor)
@@ -158,6 +182,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
setJsonEditorContent(''); setJsonEditorContent('');
setJsonEditorExpanded(false); setJsonEditorExpanded(false);
setJsonValidationMessage({ type: null, message: '' }); setJsonValidationMessage({ type: null, message: '' });
setAvailableCategories(null);
setSelectedFile(null); setSelectedFile(null);
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
@@ -177,6 +202,8 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
const appState = { const appState = {
pkParams, pkParams,
days, days,
profiles: profiles || [],
activeProfileId: activeProfileId || '',
therapeuticRange, therapeuticRange,
doseIncrement, doseIncrement,
uiSettings, uiSettings,
@@ -197,6 +224,8 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
const appState = { const appState = {
pkParams, pkParams,
days, days,
profiles: profiles || [],
activeProfileId: activeProfileId || '',
therapeuticRange, therapeuticRange,
doseIncrement, doseIncrement,
uiSettings, uiSettings,
@@ -292,6 +321,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
const validateJsonContent = (content: string) => { const validateJsonContent = (content: string) => {
if (!content.trim()) { if (!content.trim()) {
setJsonValidationMessage({ type: null, message: '' }); setJsonValidationMessage({ type: null, message: '' });
setAvailableCategories(null);
return; return;
} }
@@ -304,6 +334,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
type: 'error', type: 'error',
message: t('importParseError'), message: t('importParseError'),
}); });
setAvailableCategories(null);
return; return;
} }
@@ -314,9 +345,21 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
type: 'error', type: 'error',
message: validation.errors.join(', '), message: validation.errors.join(', '),
}); });
setAvailableCategories(null);
return; return;
} }
// Detect which categories are present in the JSON
const categories = {
schedules: !!(importData.data.profiles || importData.data.schedules),
diagramSettings: !!importData.data.diagramSettings,
simulationSettings: !!importData.data.simulationSettings,
pharmacoSettings: !!importData.data.pharmacoSettings,
advancedSettings: !!importData.data.advancedSettings,
otherData: !!importData.data.otherData,
};
setAvailableCategories(categories);
if (validation.warnings.length > 0) { if (validation.warnings.length > 0) {
// Show success with warnings - warnings will be displayed separately // Show success with warnings - warnings will be displayed separately
setJsonValidationMessage({ setJsonValidationMessage({
@@ -336,6 +379,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
type: 'error', type: 'error',
message: t('pasteInvalidJson'), message: t('pasteInvalidJson'),
}); });
setAvailableCategories(null);
} }
}; };
@@ -400,15 +444,26 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
const currentState = { const currentState = {
pkParams, pkParams,
days, days,
profiles: profiles || [],
activeProfileId: activeProfileId || '',
therapeuticRange, therapeuticRange,
doseIncrement, doseIncrement,
uiSettings, uiSettings,
steadyStateConfig: { daysOnMedication: pkParams.advanced.steadyStateDays }, steadyStateConfig: { daysOnMedication: pkParams.advanced.steadyStateDays },
}; };
const newState = importSettings(currentState, importData.data, importOptions);
// Apply schedules const importOpts: ImportOptions = {
if (newState.days && importOptions.includeSchedules && onImportDays) { mergeProfiles: mergeProfiles
};
const newState = importSettings(currentState, importData.data, importOptions, importOpts);
// Apply profiles (new approach)
if (newState.profiles && newState.activeProfileId && importOptions.includeSchedules && onImportProfiles) {
onImportProfiles(newState.profiles, newState.activeProfileId);
}
// Fallback: Apply schedules (legacy)
else if (newState.days && importOptions.includeSchedules && onImportDays) {
onImportDays(newState.days); onImportDays(newState.days);
} }
@@ -447,7 +502,11 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
onClose(); onClose();
} catch (error) { } catch (error) {
console.error('Import error:', error); console.error('Import error:', error);
alert(t('importError')); if (error instanceof Error && error.message.includes('exceed maximum')) {
alert(error.message);
} else {
alert(t('importError'));
}
} }
}; };
@@ -463,7 +522,15 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
// Handle delete selected data // Handle delete selected data
const handleDeleteData = () => { const handleDeleteData = () => {
const hasAnySelected = Object.values(deletionOptions).some(v => v); // Check if any actual deletion categories are selected (excluding restoreExamples which is just an option)
const hasAnySelected =
deletionOptions.includeSchedules ||
deletionOptions.includeDiagramSettings ||
deletionOptions.includeSimulationSettings ||
deletionOptions.includePharmacoSettings ||
deletionOptions.includeAdvancedSettings ||
deletionOptions.includeOtherData;
if (!hasAnySelected) { if (!hasAnySelected) {
alert(t('deleteNoOptionsSelected')); alert(t('deleteNoOptionsSelected'));
return; return;
@@ -544,6 +611,33 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
{t('exportOptionSchedules')} {t('exportOptionSchedules')}
</Label> </Label>
</div> </div>
{exportOptions.includeSchedules && profiles && profiles.length > 1 && (
<div className="flex items-center gap-3 pl-8">
<Switch
id="export-all-profiles"
checked={exportOptions.exportAllProfiles ?? true}
onCheckedChange={checked =>
setExportOptions({ ...exportOptions, exportAllProfiles: checked })
}
/>
<Label htmlFor="export-all-profiles" className="text-sm text-muted-foreground">
{t('exportAllProfiles')} ({profiles.length} {t('profiles')})
</Label>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<div className="text-xs max-w-xs">{formatContent(t('exportAllProfilesTooltip'))}</div>
</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Switch <Switch
id="export-diagram" id="export-diagram"
@@ -666,6 +760,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
<Switch <Switch
id="import-schedules" id="import-schedules"
checked={importOptions.includeSchedules} checked={importOptions.includeSchedules}
disabled={availableCategories !== null && !availableCategories.schedules}
onCheckedChange={checked => onCheckedChange={checked =>
setImportOptions({ ...importOptions, includeSchedules: checked }) setImportOptions({ ...importOptions, includeSchedules: checked })
} }
@@ -674,10 +769,36 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
{t('exportOptionSchedules')} {t('exportOptionSchedules')}
</Label> </Label>
</div> </div>
{importOptions.includeSchedules && profiles && (
<div className="flex items-center gap-3 pl-8">
<Switch
id="merge-profiles"
checked={mergeProfiles}
onCheckedChange={setMergeProfiles}
/>
<Label htmlFor="merge-profiles" className="text-sm text-muted-foreground">
{t('mergeProfiles')}
</Label>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<div className="text-xs max-w-xs">{formatContent(t('mergeProfilesTooltip'))}</div>
</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Switch <Switch
id="import-diagram" id="import-diagram"
checked={importOptions.includeDiagramSettings} checked={importOptions.includeDiagramSettings}
disabled={availableCategories !== null && !availableCategories.diagramSettings}
onCheckedChange={checked => onCheckedChange={checked =>
setImportOptions({ ...importOptions, includeDiagramSettings: checked }) setImportOptions({ ...importOptions, includeDiagramSettings: checked })
} }
@@ -690,6 +811,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
<Switch <Switch
id="import-simulation" id="import-simulation"
checked={importOptions.includeSimulationSettings} checked={importOptions.includeSimulationSettings}
disabled={availableCategories !== null && !availableCategories.simulationSettings}
onCheckedChange={checked => onCheckedChange={checked =>
setImportOptions({ ...importOptions, includeSimulationSettings: checked }) setImportOptions({ ...importOptions, includeSimulationSettings: checked })
} }
@@ -702,6 +824,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
<Switch <Switch
id="import-pharmaco" id="import-pharmaco"
checked={importOptions.includePharmacoSettings} checked={importOptions.includePharmacoSettings}
disabled={availableCategories !== null && !availableCategories.pharmacoSettings}
onCheckedChange={checked => onCheckedChange={checked =>
setImportOptions({ ...importOptions, includePharmacoSettings: checked }) setImportOptions({ ...importOptions, includePharmacoSettings: checked })
} }
@@ -714,6 +837,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
<Switch <Switch
id="import-advanced" id="import-advanced"
checked={importOptions.includeAdvancedSettings} checked={importOptions.includeAdvancedSettings}
disabled={availableCategories !== null && !availableCategories.advancedSettings}
onCheckedChange={checked => onCheckedChange={checked =>
setImportOptions({ ...importOptions, includeAdvancedSettings: checked }) setImportOptions({ ...importOptions, includeAdvancedSettings: checked })
} }
@@ -726,6 +850,7 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
<Switch <Switch
id="import-other" id="import-other"
checked={importOptions.includeOtherData} checked={importOptions.includeOtherData}
disabled={availableCategories !== null && !availableCategories.otherData}
onCheckedChange={checked => onCheckedChange={checked =>
setImportOptions({ ...importOptions, includeOtherData: checked }) setImportOptions({ ...importOptions, includeOtherData: checked })
} }
@@ -945,6 +1070,20 @@ const DataManagementModal: React.FC<DataManagementModalProps> = ({
{t('exportOptionSchedules')} {t('exportOptionSchedules')}
</Label> </Label>
</div> </div>
{deletionOptions.includeSchedules && (
<div className="flex items-center gap-3 pl-8">
<Switch
id="delete-restore-examples"
checked={deletionOptions.restoreExamples ?? false}
onCheckedChange={checked =>
setDeletionOptions({ ...deletionOptions, restoreExamples: checked })
}
/>
<Label htmlFor="delete-restore-examples" className="text-sm text-muted-foreground">
{t('deleteRestoreExamples')}
</Label>
</div>
)}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Switch <Switch
id="delete-diagram" id="delete-diagram"

View File

@@ -0,0 +1,223 @@
/**
* Profile Selector Component
*
* Allows users to manage medication schedule profiles with create, save,
* save-as, and delete functionality. Provides a combobox-style interface
* for profile selection and management.
*
* @author Andreas Weyer
* @license MIT
*/
import React, { useState } from 'react';
import { Card, CardContent } from './ui/card';
import { Label } from './ui/label';
import { Input } from './ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { Save, Trash2, Plus } from 'lucide-react';
import { IconButtonWithTooltip } from './ui/icon-button-with-tooltip';
import { MAX_PROFILES, type ScheduleProfile } from '../constants/defaults';
interface ProfileSelectorProps {
profiles: ScheduleProfile[];
activeProfileId: string;
hasUnsavedChanges: boolean;
onSwitchProfile: (profileId: string) => void;
onSaveProfile: () => void;
onSaveProfileAs: (name: string) => string | null;
onDeleteProfile: (profileId: string) => boolean;
t: (key: string) => string;
}
export const ProfileSelector: React.FC<ProfileSelectorProps> = ({
profiles,
activeProfileId,
hasUnsavedChanges,
onSwitchProfile,
onSaveProfile,
onSaveProfileAs,
onDeleteProfile,
t,
}) => {
const [newProfileName, setNewProfileName] = useState('');
const [isSaveAsMode, setIsSaveAsMode] = useState(false);
const activeProfile = profiles.find(p => p.id === activeProfileId);
const canDelete = profiles.length > 1;
const canCreateNew = profiles.length < MAX_PROFILES;
const handleSelectChange = (value: string) => {
if (value === '__new__') {
// Enter "save as" mode
setIsSaveAsMode(true);
setNewProfileName('');
} else {
// Confirm before switching if there are unsaved changes
if (hasUnsavedChanges) {
if (!window.confirm(t('profileSwitchUnsavedConfirm'))) {
return;
}
}
onSwitchProfile(value);
setIsSaveAsMode(false);
}
};
const handleSaveAs = () => {
if (!newProfileName.trim()) {
return;
}
// Check for duplicate names
const isDuplicate = profiles.some(
p => p.name.toLowerCase() === newProfileName.trim().toLowerCase()
);
let finalName = newProfileName.trim();
if (isDuplicate) {
// Find next available suffix
let suffix = 2;
while (profiles.some(p => p.name.toLowerCase() === `${newProfileName.trim()} (${suffix})`.toLowerCase())) {
suffix++;
}
finalName = `${newProfileName.trim()} (${suffix})`;
}
const newProfileId = onSaveProfileAs(finalName);
if (newProfileId) {
setIsSaveAsMode(false);
setNewProfileName('');
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSaveAs();
} else if (e.key === 'Escape') {
setIsSaveAsMode(false);
setNewProfileName('');
}
};
const handleDelete = () => {
if (activeProfile && canDelete) {
if (window.confirm(t('profileDeleteConfirm')?.replace('{name}', activeProfile.name))) {
onDeleteProfile(activeProfile.id);
}
}
};
return (
<Card className="mb-4">
<CardContent className="pt-6">
<div className="space-y-2">
{/* Title label */}
<Label htmlFor="profile-selector" className="text-sm font-medium">
{t('savedPlans')}
</Label>
{/* Profile selector with integrated buttons */}
<div className="flex items-stretch">
{/* Profile selector / name input */}
{isSaveAsMode ? (
<Input
id="profile-selector"
type="text"
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('profileSaveAsPlaceholder')}
autoFocus
className="h-9 rounded-r-none border-r-0 w-[360px] bg-background"
/>
) : (
<Select
value={activeProfileId}
onValueChange={handleSelectChange}
>
<SelectTrigger id="profile-selector" className="h-9 rounded-r-none border-r-0 w-[360px] bg-background">
<SelectValue>
{activeProfile?.name}
{hasUnsavedChanges && ' *'}
</SelectValue>
</SelectTrigger>
<SelectContent>
{profiles.map(profile => (
<SelectItem key={profile.id} value={profile.id}>
{profile.name}
</SelectItem>
))}
{canCreateNew && (
<>
<div className="my-1 h-px bg-border" />
<Tooltip>
<TooltipTrigger asChild>
<SelectItem value="__new__">
<div className="flex items-center gap-2">
<Plus className="h-4 w-4" />
<span>{t('profileSaveAsNewProfile')}</span>
</div>
</SelectItem>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">{t('profileSaveAs')}</p>
</TooltipContent>
</Tooltip>
</>
)}
</SelectContent>
</Select>
)}
{/* Save button - integrated */}
<IconButtonWithTooltip
onClick={isSaveAsMode ? handleSaveAs : onSaveProfile}
icon={<Save className="h-4 w-4" />}
tooltip={isSaveAsMode ? t('profileSaveAs') : t('profileSave')}
disabled={(isSaveAsMode && !newProfileName.trim()) || (!isSaveAsMode && !hasUnsavedChanges)}
variant="outline"
size="icon"
className="rounded-none border-r-0"
/>
{/* Delete button - integrated */}
<IconButtonWithTooltip
onClick={handleDelete}
icon={<Trash2 className="h-4 w-4" />}
tooltip={canDelete ? t('profileDelete') : t('profileDeleteDisabled')}
disabled={!canDelete || isSaveAsMode}
variant="outline"
size="icon"
className="rounded-l-none text-destructive hover:bg-destructive hover:text-destructive-foreground"
/>
</div>
{/* Helper text for save-as mode */}
{isSaveAsMode && (
<div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground flex-1">
{t('profileSaveAsHelp')}
</p>
<button
onClick={() => {
setIsSaveAsMode(false);
setNewProfileName('');
}}
className="text-xs text-muted-foreground hover:text-foreground underline"
>
{t('cancel')}
</button>
</div>
)}
</div>
</CardContent>
</Card>
);
};

View File

@@ -26,7 +26,8 @@ const versionInfo = versionJsonDefault && Object.keys(versionJsonDefault).length
gitDate: 'unknown', gitDate: 'unknown',
}; };
export const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v9'; // Incremented for urinePh mode structure change export const LOCAL_STORAGE_KEY = 'medPlanAssistantState_v10'; // Incremented for profile-based schedule management
export const MAX_PROFILES = 20; // Maximum number of schedule profiles allowed
export const PROJECT_REPOSITORY_URL = 'https://git.11001001.org/cbaoth/med-plan-assistant'; export const PROJECT_REPOSITORY_URL = 'https://git.11001001.org/cbaoth/med-plan-assistant';
export const APP_VERSION = versionInfo.version; export const APP_VERSION = versionInfo.version;
export const BUILD_INFO = versionInfo; export const BUILD_INFO = versionInfo;
@@ -80,6 +81,14 @@ export interface DayGroup {
doses: DayDose[]; doses: DayDose[];
} }
export interface ScheduleProfile {
id: string;
name: string;
days: DayGroup[];
createdAt: string;
modifiedAt: string;
}
export interface SteadyStateConfig { export interface SteadyStateConfig {
daysOnMedication: string; daysOnMedication: string;
} }
@@ -107,7 +116,9 @@ export interface UiSettings {
export interface AppState { export interface AppState {
pkParams: PkParams; pkParams: PkParams;
days: DayGroup[]; days: DayGroup[]; // Kept for backwards compatibility during migration
profiles: ScheduleProfile[];
activeProfileId: string;
steadyStateConfig: SteadyStateConfig; steadyStateConfig: SteadyStateConfig;
therapeuticRange: TherapeuticRange; therapeuticRange: TherapeuticRange;
doseIncrement: string; doseIncrement: string;
@@ -133,47 +144,94 @@ export interface ConcentrationPoint {
} }
// Default application state // Default application state
export const getDefaultState = (): AppState => ({ export const getDefaultState = (): AppState => {
pkParams: { const now = new Date().toISOString();
damph: { halfLife: '11' },
ldx: { const profiles: ScheduleProfile[] = [
halfLife: '0.8',
absorptionHalfLife: '0.7' // Updated from 0.9 for better ~1h Tmax of prodrug
},
advanced: {
standardVd: { preset: 'adult', customValue: '377', bodyWeight: '70' }, // Adult: 377L (Roberts 2015), Child: ~150-200L, Weight-based: ~5.4 L/kg
foodEffect: { enabled: false, tmaxDelay: '1.0' }, // hours delay
urinePh: { mode: 'normal' }, // 'normal' (6-7.5), 'acidic' (<6), 'alkaline' (>7.5)
fOral: String(DEFAULT_F_ORAL), // 0.96 bioavailability
steadyStateDays: '7' // days of prior medication history
}
},
days: [
{ {
id: 'day-template', id: 'profile-default-1',
isTemplate: true, name: 'Single Morning Dose',
doses: [ createdAt: now,
{ id: 'dose-1', time: '06:30', ldx: '25' }, modifiedAt: now,
{ id: 'dose-2', time: '14:30', ldx: '15' }, days: [
{ id: 'dose-4', time: '22:15', ldx: '15' }, {
id: 'day-template',
isTemplate: true,
doses: [
{ id: 'dose-1', time: '08:00', ldx: '30' }
]
}
]
},
{
id: 'profile-default-2',
name: 'Twice Daily',
createdAt: now,
modifiedAt: now,
days: [
{
id: 'day-template',
isTemplate: true,
doses: [
{ id: 'dose-1', time: '08:00', ldx: '20' },
{ id: 'dose-2', time: '14:00', ldx: '20' }
]
}
]
},
{
id: 'profile-default-3',
name: 'Three Times Daily',
createdAt: now,
modifiedAt: now,
days: [
{
id: 'day-template',
isTemplate: true,
doses: [
{ id: 'dose-1', time: '08:00', ldx: '20' },
{ id: 'dose-2', time: '14:00', ldx: '20' },
{ id: 'dose-3', time: '20:00', ldx: '20' }
]
}
] ]
} }
], ];
steadyStateConfig: { daysOnMedication: '7' }, // kept for backwards compatibility, now sourced from pkParams.advanced
therapeuticRange: { min: '', max: '' }, // users should personalize based on their response return {
doseIncrement: '2.5', pkParams: {
uiSettings: { damph: { halfLife: '11' },
showDayTimeOnXAxis: '24h', ldx: {
showTemplateDay: true, halfLife: '0.8',
chartView: 'both', absorptionHalfLife: '0.7' // Updated from 0.9 for better ~1h Tmax of prodrug
yAxisMin: '', },
yAxisMax: '', advanced: {
simulationDays: '5', standardVd: { preset: 'adult', customValue: '377', bodyWeight: '70' }, // Adult: 377L (Roberts 2015), Child: ~150-200L, Weight-based: ~5.4 L/kg
displayedDays: '2', foodEffect: { enabled: false, tmaxDelay: '1.0' }, // hours delay
showTherapeuticRange: false, urinePh: { mode: 'normal' }, // 'normal' (6-7.5), 'acidic' (<6), 'alkaline' (>7.5)
showIntakeTimeLines: false, fOral: String(DEFAULT_F_ORAL), // 0.96 bioavailability
steadyStateDaysEnabled: true, steadyStateDays: '7' // days of prior medication history
stickyChart: false, }
theme: 'system', },
} days: profiles[0].days, // For backwards compatibility, use first profile's days
}); profiles,
activeProfileId: profiles[0].id,
steadyStateConfig: { daysOnMedication: '7' }, // kept for backwards compatibility, now sourced from pkParams.advanced
therapeuticRange: { min: '', max: '' }, // users should personalize based on their response
doseIncrement: '2.5',
uiSettings: {
showDayTimeOnXAxis: '24h',
showTemplateDay: true,
chartView: 'both',
yAxisMin: '',
yAxisMax: '',
simulationDays: '5',
displayedDays: '2',
showTherapeuticRange: false,
showIntakeTimeLines: false,
steadyStateDaysEnabled: true,
stickyChart: false,
theme: 'system',
}
};
};

View File

@@ -10,7 +10,7 @@
*/ */
import React from 'react'; import React from 'react';
import { LOCAL_STORAGE_KEY, getDefaultState, MAX_DOSES_PER_DAY, type AppState, type DayGroup, type DayDose } from '../constants/defaults'; import { LOCAL_STORAGE_KEY, getDefaultState, MAX_DOSES_PER_DAY, MAX_PROFILES, type AppState, type DayGroup, type DayDose, type ScheduleProfile } from '../constants/defaults';
export const useAppState = () => { export const useAppState = () => {
const [appState, setAppState] = React.useState<AppState>(getDefaultState); const [appState, setAppState] = React.useState<AppState>(getDefaultState);
@@ -94,51 +94,45 @@ export const useAppState = () => {
return value; return value;
}; };
// Validate basic pkParams // Migrate from old days-only format to profile-based format
if (migratedPkParams.basic) { let migratedProfiles: ScheduleProfile[] = defaults.profiles;
migratedPkParams.basic.eliminationHalfLife = validateNumericField( let migratedActiveProfileId: string = defaults.activeProfileId;
migratedPkParams.basic.eliminationHalfLife, let migratedDays: DayGroup[] = defaults.days;
defaults.pkParams.basic.eliminationHalfLife
);
migratedPkParams.basic.bodyWeight = validateNumericField(
migratedPkParams.basic.bodyWeight,
defaults.pkParams.basic.bodyWeight
);
}
// Validate advanced pkParams if (parsedState.profiles && Array.isArray(parsedState.profiles)) {
if (migratedPkParams.advanced) { // New format with profiles
migratedPkParams.advanced.conversionEfficiency = validateNumericField( migratedProfiles = parsedState.profiles;
migratedPkParams.advanced.conversionEfficiency, migratedActiveProfileId = parsedState.activeProfileId || parsedState.profiles[0]?.id || defaults.activeProfileId;
defaults.pkParams.advanced.conversionEfficiency
); // Validate activeProfileId exists in profiles
migratedPkParams.advanced.bioavailability = validateNumericField( const activeProfile = migratedProfiles.find(p => p.id === migratedActiveProfileId);
migratedPkParams.advanced.bioavailability, if (!activeProfile && migratedProfiles.length > 0) {
defaults.pkParams.advanced.bioavailability migratedActiveProfileId = migratedProfiles[0].id;
); }
migratedPkParams.advanced.customVolumeOfDistribution = validateNumericField(
migratedPkParams.advanced.customVolumeOfDistribution, // Set days from active profile
defaults.pkParams.advanced.customVolumeOfDistribution migratedDays = activeProfile?.days || defaults.days;
); } else if (parsedState.days) {
migratedPkParams.advanced.absorptionDelay = validateNumericField( // Old format: migrate days to default profile
migratedPkParams.advanced.absorptionDelay, const now = new Date().toISOString();
defaults.pkParams.advanced.absorptionDelay migratedProfiles = [{
); id: `profile-migrated-${Date.now()}`,
migratedPkParams.advanced.absorptionRateConstant = validateNumericField( name: 'Default',
migratedPkParams.advanced.absorptionRateConstant, days: parsedState.days,
defaults.pkParams.advanced.absorptionRateConstant createdAt: now,
); modifiedAt: now
migratedPkParams.advanced.mealDelayFactor = validateNumericField( }];
migratedPkParams.advanced.mealDelayFactor, migratedActiveProfileId = migratedProfiles[0].id;
defaults.pkParams.advanced.mealDelayFactor migratedDays = parsedState.days;
);
} }
setAppState({ setAppState({
...defaults, ...defaults,
...parsedState, ...parsedState,
pkParams: migratedPkParams, pkParams: migratedPkParams,
days: parsedState.days || defaults.days, days: migratedDays,
profiles: migratedProfiles,
activeProfileId: migratedActiveProfileId,
uiSettings: migratedUiSettings, uiSettings: migratedUiSettings,
}); });
} }
@@ -154,6 +148,8 @@ export const useAppState = () => {
const stateToSave = { const stateToSave = {
pkParams: appState.pkParams, pkParams: appState.pkParams,
days: appState.days, days: appState.days,
profiles: appState.profiles,
activeProfileId: appState.activeProfileId,
steadyStateConfig: appState.steadyStateConfig, steadyStateConfig: appState.steadyStateConfig,
therapeuticRange: appState.therapeuticRange, therapeuticRange: appState.therapeuticRange,
doseIncrement: appState.doseIncrement, doseIncrement: appState.doseIncrement,
@@ -364,6 +360,153 @@ export const useAppState = () => {
})); }));
}; };
// Profile management functions
const getActiveProfile = (): ScheduleProfile | undefined => {
return appState.profiles.find(p => p.id === appState.activeProfileId);
};
const createProfile = (name: string, cloneFromId?: string): string | null => {
if (appState.profiles.length >= MAX_PROFILES) {
console.warn(`Cannot create profile: Maximum of ${MAX_PROFILES} profiles reached`);
return null;
}
const now = new Date().toISOString();
const newProfileId = `profile-${Date.now()}`;
let days: DayGroup[];
if (cloneFromId) {
const sourceProfile = appState.profiles.find(p => p.id === cloneFromId);
days = sourceProfile ? JSON.parse(JSON.stringify(sourceProfile.days)) : appState.days;
} else {
// Create with current days
days = JSON.parse(JSON.stringify(appState.days));
}
// Regenerate IDs for cloned days/doses
days = days.map(day => ({
...day,
id: `day-${Date.now()}-${Math.random()}`,
doses: day.doses.map(dose => ({
...dose,
id: `dose-${Date.now()}-${Math.random()}`
}))
}));
const newProfile: ScheduleProfile = {
id: newProfileId,
name,
days,
createdAt: now,
modifiedAt: now
};
setAppState(prev => ({
...prev,
profiles: [...prev.profiles, newProfile]
}));
return newProfileId;
};
const deleteProfile = (profileId: string): boolean => {
if (appState.profiles.length <= 1) {
console.warn('Cannot delete last profile');
return false;
}
const profileIndex = appState.profiles.findIndex(p => p.id === profileId);
if (profileIndex === -1) {
console.warn('Profile not found');
return false;
}
setAppState(prev => {
const newProfiles = prev.profiles.filter(p => p.id !== profileId);
// If we're deleting the active profile, switch to first remaining profile
let newActiveProfileId = prev.activeProfileId;
if (profileId === prev.activeProfileId) {
newActiveProfileId = newProfiles[0].id;
}
return {
...prev,
profiles: newProfiles,
activeProfileId: newActiveProfileId,
days: newProfiles.find(p => p.id === newActiveProfileId)?.days || prev.days
};
});
return true;
};
const switchProfile = (profileId: string) => {
const profile = appState.profiles.find(p => p.id === profileId);
if (!profile) {
console.warn('Profile not found');
return;
}
setAppState(prev => ({
...prev,
activeProfileId: profileId,
days: profile.days
}));
};
const saveProfile = () => {
const now = new Date().toISOString();
setAppState(prev => ({
...prev,
profiles: prev.profiles.map(p =>
p.id === prev.activeProfileId
? { ...p, days: JSON.parse(JSON.stringify(prev.days)), modifiedAt: now }
: p
)
}));
};
const saveProfileAs = (newName: string): string | null => {
const newProfileId = createProfile(newName, undefined);
if (newProfileId) {
// Save current days to the new profile and switch to it
const now = new Date().toISOString();
setAppState(prev => ({
...prev,
profiles: prev.profiles.map(p =>
p.id === newProfileId
? { ...p, days: JSON.parse(JSON.stringify(prev.days)), modifiedAt: now }
: p
),
activeProfileId: newProfileId
}));
}
return newProfileId;
};
const updateProfileName = (profileId: string, newName: string) => {
setAppState(prev => ({
...prev,
profiles: prev.profiles.map(p =>
p.id === profileId
? { ...p, name: newName, modifiedAt: new Date().toISOString() }
: p
)
}));
};
const hasUnsavedChanges = (): boolean => {
const activeProfile = getActiveProfile();
if (!activeProfile) return false;
return JSON.stringify(activeProfile.days) !== JSON.stringify(appState.days);
};
return { return {
appState, appState,
isLoaded, isLoaded,
@@ -377,6 +520,15 @@ export const useAppState = () => {
removeDoseFromDay, removeDoseFromDay,
updateDoseInDay, updateDoseInDay,
updateDoseFieldInDay, updateDoseFieldInDay,
sortDosesInDay sortDosesInDay,
// Profile management
getActiveProfile,
createProfile,
deleteProfile,
switchProfile,
saveProfile,
saveProfileAs,
updateProfileName,
hasUnsavedChanges
}; };
}; };

View File

@@ -10,7 +10,7 @@ export const de = {
lisdexamfetamine: "Lisdexamfetamin", lisdexamfetamine: "Lisdexamfetamin",
lisdexamfetamineShort: "LDX", lisdexamfetamineShort: "LDX",
both: "Beide", both: "Beide",
regularPlanOverlayShort: "Reg.", regularPlanOverlayShort: "Basis",
// Language selector // Language selector
languageSelectorLabel: "Sprache", languageSelectorLabel: "Sprache",
@@ -23,7 +23,7 @@ export const de = {
themeSelectorSystem: "💻 System", themeSelectorSystem: "💻 System",
// Dose Schedule // Dose Schedule
myPlan: "Mein Plan", myPlan: "Mein Zeitplan",
morning: "Morgens", morning: "Morgens",
midday: "Mittags", midday: "Mittags",
afternoon: "Nachmittags", afternoon: "Nachmittags",
@@ -32,8 +32,29 @@ export const de = {
doseWithFood: "Mit Nahrung eingenommen (verzögert Absorption ~1h)", doseWithFood: "Mit Nahrung eingenommen (verzögert Absorption ~1h)",
doseFasted: "Nüchtern eingenommen (normale Absorption)", doseFasted: "Nüchtern eingenommen (normale Absorption)",
// Schedule Management
savedPlans: "Gespeicherte Zeitpläne",
profileSaveAsNewProfile: "Als neuen Zeitplan speichern",
profileSave: "Änderungen im aktuellen Zeitplan speichern",
profileSaveAs: "Neuen Zeitplan mit aktueller Konfiguration erstellen",
profileDelete: "Diesen Zeitplan löschen",
profileDeleteDisabled: "Der letzte Zeitplan kann nicht gelöscht werden",
profileDeleteConfirm: "Möchten Sie den Zeitplan '{name}' wirklich löschen?",
profileSaveAsPlaceholder: "Name für den neuen Zeitplan...",
profileSaveAsHelp: "Geben Sie einen Namen für den neuen Zeitplan ein und drücken Sie Enter oder klicken Sie auf Speichern",
profileSwitchUnsavedConfirm: "Sie haben ungespeicherte Änderungen. Beim Wechseln des Zeitplans gehen diese verloren. Fortfahren?",
profiles: "Zeitpläne",
cancel: "Abbrechen",
// Export/Import schedules
exportAllProfiles: "Alle Zeitpläne exportieren",
exportAllProfilesTooltip: "__Wenn aktiviert:__ Exportiert alle gespeicherten Zeitpläne.\\n\\n__Wenn deaktiviert:__ Exportiert nur den aktuell aktiven Zeitplan. Wenn der aktive Zeitplan ungespeicherte Änderungen hat, werden diese im Export enthalten sein.",
mergeProfiles: "Mit vorhandenen Zeitplänen zusammenführen",
mergeProfilesTooltip: "Wenn aktiviert, werden importierte Zeitpläne zu Ihren vorhandenen hinzugefügt. Wenn deaktiviert, werden alle aktuellen Zeitpläne ersetzt.\\n\\n__Standard:__ **deaktiviert** (alle ersetzen)",
deleteRestoreExamples: "Beispielzeitpläne nach Löschung wiederherstellen",
// Deviations // Deviations
deviationsFromPlan: "Abweichungen vom Plan", deviationsFromPlan: "Abweichungen vom Zeitplan",
addDeviation: "Abweichung hinzufügen", addDeviation: "Abweichung hinzufügen",
day: "Tag", day: "Tag",
additional: "Zusätzlich", additional: "Zusätzlich",
@@ -53,13 +74,13 @@ export const de = {
axisLabelHours: "Stunden (h)", axisLabelHours: "Stunden (h)",
axisLabelTimeOfDay: "Tageszeit (h)", axisLabelTimeOfDay: "Tageszeit (h)",
tickNoon: "Mittag", tickNoon: "Mittag",
refLineRegularPlan: "Regulär", refLineRegularPlan: "Basis",
refLineNoDeviation: "Regulär", refLineNoDeviation: "Basis",
refLineRecovering: "Erholung", refLineRecovering: "Erholung",
refLineIrregularIntake: "Irregulär", refLineIrregularIntake: "Irregulär",
refLineDayX: "T{{x}}", refLineDayX: "T{{x}}",
refLineRegularPlanShort: "(Reg.)", refLineRegularPlanShort: "(Basis)",
refLineNoDeviationShort: "(Reg.)", refLineNoDeviationShort: "(Basis)",
refLineRecoveringShort: "(Erh.)", refLineRecoveringShort: "(Erh.)",
refLineIrregularIntakeShort: "(Irr.)", refLineIrregularIntakeShort: "(Irr.)",
refLineDayShort: "T{{x}}", refLineDayShort: "T{{x}}",
@@ -93,13 +114,13 @@ export const de = {
xAxisFormat24hDesc: "Wiederholender 0-24h Zyklus", xAxisFormat24hDesc: "Wiederholender 0-24h Zyklus",
xAxisFormat12h: "Tageszeit (12h AM/PM)", xAxisFormat12h: "Tageszeit (12h AM/PM)",
xAxisFormat12hDesc: "Wiederholend 12h Zyklus im AM/PM Format", xAxisFormat12hDesc: "Wiederholend 12h Zyklus im AM/PM Format",
showTemplateDayInChart: "Regulären Plan kontinuierlich anzeigen", showTemplateDayInChart: "Basis-Zeitplan kontinuierlich anzeigen",
showTemplateDayTooltip: "Medikationsplan als Referenz-Overlay jederzeit anzeigen.\\n\\n__Standard:__ **aktiviert**", showTemplateDayTooltip: "Führt die Simulation des Basis-Zeitplans auch dann fort, auch wenn für Tag 2+ abweichende Zeitpläne definiert sind. Die entsprechenden Plasmakonzentrationen werden, nur im Falle einer Abweichung vom Basis-Zeitplan, als zusätzliche gestrichelte Linien dargestellt.\\n\\n__Standard:__ **aktiviert**",
simulationSettings: "Simulations-Einstellungen", simulationSettings: "Simulations-Einstellungen",
showDayReferenceLines: "Tagestrenner anzeigen", showDayReferenceLines: "Tagestrenner anzeigen",
showDayReferenceLinesTooltip: "Vertikale Linien und Statusanzeigen zwischen Tagen anzeigen.\\n\\n__Standard:__ **aktiviert**", showIntakeTimeLines: "Einnahmezeitmarkierungen anzeigen", showDayReferenceLinesTooltip: "Vertikale Linien und Statusanzeigen zwischen Tagen anzeigen.\\n\\n__Standard:__ **aktiviert**", showIntakeTimeLines: "Einnahmezeitmarkierungen anzeigen",
showIntakeTimeLinesTooltip: "Vertikale gestrichelte Linien an Einnahmezeiten mit Dosis-Index-Labels anzeigen.\n\n__Standard:__ **deaktiviert**", showTherapeuticRangeLines: "Therapeutischen Bereich anzeigen ", showIntakeTimeLinesTooltip: "Vertikale gestrichelte Linien an Einnahmezeiten mit Dosis-Index-Labels anzeigen.\\n\\n__Standard:__ **deaktiviert**", showTherapeuticRangeLines: "Therapeutischen Bereich anzeigen ",
showTherapeuticRangeLinesTooltip: "Horizontale Referenzlinien für therapeutisches Min/Max anzeigen.\\n\\n__Standard:__ **aktiviert**", showTherapeuticRangeLinesTooltip: "Horizontale Referenzlinien für therapeutisches Min/Max anzeigen.\\n\\n__Standard:__ **aktiviert**",
simulationDuration: "Simulationsdauer", simulationDuration: "Simulationsdauer",
simulationDurationTooltip: "Anzahl der zu simulierenden Tage. Längere Zeiträume zeigen Steady-State.\\n\\n__Standard:__ **{{simulationDays}} Tage**", simulationDurationTooltip: "Anzahl der zu simulierenden Tage. Längere Zeiträume zeigen Steady-State.\\n\\n__Standard:__ **{{simulationDays}} Tage**",
@@ -171,7 +192,7 @@ export const de = {
resetAllSettings: "Alle Einstellungen zurücksetzen", resetAllSettings: "Alle Einstellungen zurücksetzen",
resetDiagramSettings: "Diagramm-Einstellungen zurücksetzen", resetDiagramSettings: "Diagramm-Einstellungen zurücksetzen",
resetPharmacokineticSettings: "Pharmakokinetik-Einstellungen zurücksetzen", resetPharmacokineticSettings: "Pharmakokinetik-Einstellungen zurücksetzen",
resetPlan: "Plan zurücksetzen", resetPlan: "Zeitplan zurücksetzen",
// Disclaimer Modal // Disclaimer Modal
disclaimerModalTitle: "Wichtiger medizinischer Haftungsausschluss", disclaimerModalTitle: "Wichtiger medizinischer Haftungsausschluss",
@@ -213,7 +234,7 @@ export const de = {
exportOptionPharmaco: "Pharmakokinetik-Einstellungen (Halbwertszeiten, therapeutischer Bereich)", exportOptionPharmaco: "Pharmakokinetik-Einstellungen (Halbwertszeiten, therapeutischer Bereich)",
exportOptionAdvanced: "Erweiterte Einstellungen (Gewicht, Nahrung, pH, Bioverfügbarkeit)", exportOptionAdvanced: "Erweiterte Einstellungen (Gewicht, Nahrung, pH, Bioverfügbarkeit)",
exportOptionOtherData: "Andere Daten (Design, eingeklappte Karten, Sprache, Haftungsausschluss)", exportOptionOtherData: "Andere Daten (Design, eingeklappte Karten, Sprache, Haftungsausschluss)",
exportOptionOtherDataTooltip: "UI-Präferenzen wie Design, eingeklappte Kartenstatus, Spracheinstellung und Haftungsausschluss-Bestätigung. Normalerweise nicht nötig beim Teilen von Plänen mit anderen.", exportOptionOtherDataTooltip: "UI-Präferenzen wie Design, eingeklappte Kartenstatus, Spracheinstellung und Haftungsausschluss-Bestätigung. Normalerweise nicht nötig beim Teilen von Zeitplänen mit anderen.",
exportButton: "Backup-Datei herunterladen", exportButton: "Backup-Datei herunterladen",
importButton: "Datei zum Importieren wählen", importButton: "Datei zum Importieren wählen",
importApplyButton: "Import anwenden", importApplyButton: "Import anwenden",
@@ -300,13 +321,13 @@ export const de = {
errorDailyTotalAbove200mg: "⛔ **Tagesgesamtdosis überschreitet sichere Grenzen erheblich!**\\n\\nIhre Tagesgesamtdosis **{{total}} mg** überschreitet 200 mg/Tag, was **deutlich über FDA-zugelassenen Grenzen** liegt. *Bitte konsultieren Sie Ihren Arzt.*", errorDailyTotalAbove200mg: "⛔ **Tagesgesamtdosis überschreitet sichere Grenzen erheblich!**\\n\\nIhre Tagesgesamtdosis **{{total}} mg** überschreitet 200 mg/Tag, was **deutlich über FDA-zugelassenen Grenzen** liegt. *Bitte konsultieren Sie Ihren Arzt.*",
// Day-based schedule // Day-based schedule
regularPlan: "Regulärer Plan", regularPlan: "Basis-Zeitplan",
deviatingPlan: "Abweichung vom Plan", deviatingPlan: "Abweichung vom Zeitplan",
alternativePlan: "Alternativer Plan", alternativePlan: "Alternativer Zeitplan",
regularPlanOverlay: "Regulär", regularPlanOverlay: "Basis",
dayNumber: "Tag {{number}}", dayNumber: "Tag {{number}}",
cloneDay: "Tag klonen", cloneDay: "Tag klonen",
addDay: "Tag hinzufügen", addDay: "Tag hinzufügen (alternativer Zeitplan)",
addDose: "Dosis hinzufügen", addDose: "Dosis hinzufügen",
removeDose: "Dosis entfernen", removeDose: "Dosis entfernen",
removeDay: "Tag entfernen", removeDay: "Tag entfernen",
@@ -314,17 +335,17 @@ export const de = {
expandDay: "Tag ausklappen", expandDay: "Tag ausklappen",
dose: "Dosis", dose: "Dosis",
doses: "Dosen", doses: "Dosen",
comparedToRegularPlan: "verglichen mit regulärem Plan", comparedToRegularPlan: "verglichen mit Basis-Zeitplan",
time: "Zeitpunkt der Einnahme", time: "Zeitpunkt der Einnahme",
ldx: "LDX", ldx: "LDX",
damph: "d-amph", damph: "d-amph",
// URL sharing // URL sharing
sharePlan: "Plan teilen", sharePlan: "Zeitplan teilen",
viewingSharedPlan: "Du siehst einen geteilten Plan", viewingSharedPlan: "Du siehst einen geteilten Zeitplan",
saveAsMyPlan: "Als meinen Plan speichern", saveAsMyPlan: "Als meinen Zeitplan speichern",
discardSharedPlan: "Verwerfen", discardSharedPlan: "Verwerfen",
planCopiedToClipboard: "Plan-Link in Zwischenablage kopiert!", planCopiedToClipboard: "Zeitplan-Link in Zwischenablage kopiert!",
// Time picker // Time picker
timePickerHour: "Stunde", timePickerHour: "Stunde",

View File

@@ -10,7 +10,7 @@ export const en = {
lisdexamfetamine: "Lisdexamfetamine", lisdexamfetamine: "Lisdexamfetamine",
lisdexamfetamineShort: "LDX", lisdexamfetamineShort: "LDX",
both: "Both", both: "Both",
regularPlanOverlayShort: "Reg.", regularPlanOverlayShort: "Base",
// Language selector // Language selector
languageSelectorLabel: "Language", languageSelectorLabel: "Language",
@@ -23,7 +23,7 @@ export const en = {
themeSelectorSystem: "💻 System", themeSelectorSystem: "💻 System",
// Dose Schedule // Dose Schedule
myPlan: "My Plan", myPlan: "My Schedule",
morning: "Morning", morning: "Morning",
midday: "Midday", midday: "Midday",
afternoon: "Afternoon", afternoon: "Afternoon",
@@ -32,12 +32,33 @@ export const en = {
doseWithFood: "Taken with food (delays absorption ~1h)", doseWithFood: "Taken with food (delays absorption ~1h)",
doseFasted: "Taken fasted (normal absorption)", doseFasted: "Taken fasted (normal absorption)",
// Schedule Management
savedPlans: "Saved Schedules",
profileSaveAsNewProfile: "Save as new schedule",
profileSave: "Save changes to current schedule",
profileSaveAs: "Create new schedule with current configuration",
profileDelete: "Delete this schedule",
profileDeleteDisabled: "Cannot delete the last schedule",
profileDeleteConfirm: "Are you sure you want to delete the schedule '{name}'?",
profileSaveAsPlaceholder: "Name for the new schedule...",
profileSaveAsHelp: "Enter a name for the new schedule and press Enter or click Save",
profileSwitchUnsavedConfirm: "You have unsaved changes. Switching schedules will discard them. Continue?",
profiles: "schedules",
cancel: "Cancel",
// Export/Import schedules
exportAllProfiles: "Export all schedules",
exportAllProfilesTooltip: "__When enabled:__ Exports all saved schedules.\\n\\n__When disabled:__ Exports only the currently active schedule. If the active schedule has unsaved changes, those changes will be included in the export.",
mergeProfiles: "Merge with existing schedules",
mergeProfilesTooltip: "If enabled, imported schedules will be added to your existing ones. If disabled, all current schedules will be replaced.\\n\\n__Default:__ **disabled** (replace all)",
deleteRestoreExamples: "Restore example schedules after deletion",
// Deviations // Deviations
deviationsFromPlan: "Deviations from Plan", deviationsFromPlan: "Deviations from Schedule",
addDeviation: "Add Deviation", addDeviation: "Add Deviation",
day: "Day", day: "Day",
additional: "Additional", additional: "Additional",
additionalTooltip: "Mark this if it was an extra dose instead of a replacement for a planned one.", additionalTooltip: "Mark this if it was an extra dose instead of a replacement for a scheduled one.",
// Suggestions // Suggestions
whatIf: "What if?", whatIf: "What if?",
@@ -53,13 +74,13 @@ export const en = {
axisLabelHours: "Hours (h)", axisLabelHours: "Hours (h)",
axisLabelTimeOfDay: "Time of Day (h)", axisLabelTimeOfDay: "Time of Day (h)",
tickNoon: "Noon", tickNoon: "Noon",
refLineRegularPlan: "Regular", refLineRegularPlan: "Baseline",
refLineNoDeviation: "Regular", refLineNoDeviation: "Baseline",
refLineRecovering: "Recovering", refLineRecovering: "Recovering",
refLineIrregularIntake: "Irregular", refLineIrregularIntake: "Irregular",
refLineDayX: "D{{x}}", refLineDayX: "D{{x}}",
refLineRegularPlanShort: "(Reg.)", refLineRegularPlanShort: "(Base)",
refLineNoDeviationShort: "(Reg.)", // currently the same as above (day# > 1 with curve identical to day1 / regular plan) refLineNoDeviationShort: "(Base)", // currently the same as above (day# > 1 with curve identical to day1 / baseline schedule)
refLineRecoveringShort: "(Rec.)", refLineRecoveringShort: "(Rec.)",
refLineIrregularIntakeShort: "(Ireg.)", refLineIrregularIntakeShort: "(Ireg.)",
refLineDayShort: "D{{x}}", refLineDayShort: "D{{x}}",
@@ -92,12 +113,12 @@ export const en = {
xAxisFormat24hDesc: "Repeating 0-24h cycle", xAxisFormat24hDesc: "Repeating 0-24h cycle",
xAxisFormat12h: "Time of Day (12h AM/PM)", xAxisFormat12h: "Time of Day (12h AM/PM)",
xAxisFormat12hDesc: "Repeating 12h cycle in AM/PM format", xAxisFormat12hDesc: "Repeating 12h cycle in AM/PM format",
showTemplateDayInChart: "Continuously Show Regular Plan", showTemplateDayInChart: "Continuously Show Baseline Schedule",
showTemplateDayTooltip: "Display the regular medication plan as reference overlay at all times.\\n\\n__Default:__ **enabled**", showTemplateDayTooltip: "Continue simulating the baseline schedule even when deviations are defined for day 2+. Corresponding plasma concentrations will be shown as additional dashed lines, only if deviating from the baseline schedule.\\n\\n__Default:__ **enabled**",
simulationSettings: "Simulation Settings", simulationSettings: "Simulation Settings",
showDayReferenceLines: "Show Day Separators", showDayReferenceLines: "Show Day Separators",
showDayReferenceLinesTooltip: "Display vertical lines and status indicators separating days.\\n\\n__Default:__ **enabled**", showIntakeTimeLines: "Show Intake Time Markers", showDayReferenceLinesTooltip: "Display vertical lines and status indicators separating days.\\n\\n__Default:__ **enabled**", showIntakeTimeLines: "Show Intake Time Markers",
showIntakeTimeLinesTooltip: "Display vertical dashed lines at intake times with dose index labels.\n\n__Default:__ **disabled**", showTherapeuticRangeLines: "Show Therapeutic Range", showIntakeTimeLinesTooltip: "Display vertical dashed lines at intake times with dose index labels.\\n\\n__Default:__ **disabled**", showTherapeuticRangeLines: "Show Therapeutic Range",
showTherapeuticRangeLinesTooltip: "Display horizontal reference lines for therapeutic min/max concentrations.\\n\\n__Default:__ **enabled**", showTherapeuticRangeLinesTooltip: "Display horizontal reference lines for therapeutic min/max concentrations.\\n\\n__Default:__ **enabled**",
simulationDuration: "Simulation Duration", simulationDuration: "Simulation Duration",
simulationDurationTooltip: "Number of days to simulate. Longer periods allow steady-state observation.\\n\\n__Default:__ **{{simulationDays}} days**", simulationDurationTooltip: "Number of days to simulate. Longer periods allow steady-state observation.\\n\\n__Default:__ **{{simulationDays}} days**",
@@ -169,7 +190,7 @@ export const en = {
resetAllSettings: "Reset All Settings", resetAllSettings: "Reset All Settings",
resetDiagramSettings: "Reset Diagram Settings", resetDiagramSettings: "Reset Diagram Settings",
resetPharmacokineticSettings: "Reset Pharmacokinetic Settings", resetPharmacokineticSettings: "Reset Pharmacokinetic Settings",
resetPlan: "Reset Plan", resetPlan: "Reset Schedule",
// Disclaimer Modal // Disclaimer Modal
disclaimerModalTitle: "Important Medical Disclaimer", disclaimerModalTitle: "Important Medical Disclaimer",
@@ -205,13 +226,13 @@ export const en = {
importSettings: "Import Settings", importSettings: "Import Settings",
exportSelectWhat: "Select what to export:", exportSelectWhat: "Select what to export:",
importSelectWhat: "Select what to import:", importSelectWhat: "Select what to import:",
exportOptionSchedules: "Schedules (Day plans with doses)", exportOptionSchedules: "Schedules (Daily plans with doses)",
exportOptionDiagram: "Diagram Settings (View options, chart display)", exportOptionDiagram: "Diagram Settings (View options, chart display)",
exportOptionSimulation: "Simulation Settings (Duration, range, chart view)", exportOptionSimulation: "Simulation Settings (Duration, range, chart view)",
exportOptionPharmaco: "Pharmacokinetic Settings (Half-lives, therapeutic range)", exportOptionPharmaco: "Pharmacokinetic Settings (Half-lives, therapeutic range)",
exportOptionAdvanced: "Advanced Settings (Weight, food, pH, bioavailability)", exportOptionAdvanced: "Advanced Settings (Weight, food, pH, bioavailability)",
exportOptionOtherData: "Other Data (Theme, collapsed cards, language, disclaimer)", exportOptionOtherData: "Other Data (Theme, collapsed cards, language, disclaimer)",
exportOptionOtherDataTooltip: "UI preferences like theme, collapsed card states, language preference, and disclaimer acceptance. Typically not needed when sharing plans with others.", exportOptionOtherDataTooltip: "UI preferences like theme, collapsed card states, language preference, and disclaimer acceptance. Typically not needed when sharing schedules with others.",
exportButton: "Download Backup File", exportButton: "Download Backup File",
importButton: "Choose File to Import", importButton: "Choose File to Import",
importApplyButton: "Apply Import", importApplyButton: "Apply Import",
@@ -315,13 +336,13 @@ export const en = {
sortByTimeSorted: "Doses are sorted chronologically.", sortByTimeSorted: "Doses are sorted chronologically.",
// Day-based schedule // Day-based schedule
regularPlan: "Regular Plan", regularPlan: "Baseline Schedule",
deviatingPlan: "Deviation from Plan", deviatingPlan: "Deviation from Schedule",
alternativePlan: "Alternative Plan", alternativePlan: "Alternative Schedule",
regularPlanOverlay: "Regular", regularPlanOverlay: "Baseline",
dayNumber: "Day {{number}}", dayNumber: "Day {{number}}",
cloneDay: "Clone day", cloneDay: "Clone day",
addDay: "Add day", addDay: "Add day (alternative schedule)",
addDose: "Add dose", addDose: "Add dose",
removeDose: "Remove dose", removeDose: "Remove dose",
removeDay: "Remove day", removeDay: "Remove day",
@@ -329,17 +350,17 @@ export const en = {
expandDay: "Expand day", expandDay: "Expand day",
dose: "dose", dose: "dose",
doses: "doses", doses: "doses",
comparedToRegularPlan: "compared to regular plan", comparedToRegularPlan: "compared to baseline schedule",
time: "Time of Intake", time: "Time of Intake",
ldx: "LDX", ldx: "LDX",
damph: "d-amph", damph: "d-amph",
// URL sharing // URL sharing
sharePlan: "Share Plan", sharePlan: "Share Schedule",
viewingSharedPlan: "Viewing shared plan", viewingSharedPlan: "Viewing shared schedule",
saveAsMyPlan: "Save as My Plan", saveAsMyPlan: "Save as My Schedule",
discardSharedPlan: "Discard", discardSharedPlan: "Discard",
planCopiedToClipboard: "Plan link copied to clipboard!" planCopiedToClipboard: "Schedule link copied to clipboard!"
}; };
export default en; export default en;

View File

@@ -8,14 +8,15 @@
* @license MIT * @license MIT
*/ */
import { AppState, getDefaultState } from '../constants/defaults'; import { AppState, getDefaultState, MAX_PROFILES, type ScheduleProfile } from '../constants/defaults';
export interface ExportData { export interface ExportData {
version: string; version: string;
exportDate: string; exportDate: string;
appVersion: string; appVersion: string;
data: { data: {
schedules?: AppState['days']; schedules?: ScheduleProfile[]; // Schedule configurations (profile-based)
profiles?: ScheduleProfile[]; // Legacy: backward compatibility (renamed to schedules)
diagramSettings?: { diagramSettings?: {
showDayTimeOnXAxis: AppState['uiSettings']['showDayTimeOnXAxis']; showDayTimeOnXAxis: AppState['uiSettings']['showDayTimeOnXAxis'];
showTemplateDay: AppState['uiSettings']['showTemplateDay']; showTemplateDay: AppState['uiSettings']['showTemplateDay'];
@@ -50,6 +51,8 @@ export interface ExportData {
export interface ExportOptions { export interface ExportOptions {
includeSchedules: boolean; includeSchedules: boolean;
exportAllProfiles?: boolean; // If true, export all profiles; if false, export only active profile
restoreExamples?: boolean; // If true, restore example profiles when deleting schedules
includeDiagramSettings: boolean; includeDiagramSettings: boolean;
includeSimulationSettings: boolean; includeSimulationSettings: boolean;
includePharmacoSettings: boolean; includePharmacoSettings: boolean;
@@ -57,6 +60,10 @@ export interface ExportOptions {
includeOtherData: boolean; includeOtherData: boolean;
} }
export interface ImportOptions {
mergeProfiles?: boolean; // If true, merge imported profiles with existing; if false, replace all
}
export interface ImportValidationResult { export interface ImportValidationResult {
isValid: boolean; isValid: boolean;
warnings: string[]; warnings: string[];
@@ -83,7 +90,26 @@ export const exportSettings = (
}; };
if (options.includeSchedules) { if (options.includeSchedules) {
exportData.data.schedules = appState.days; if (options.exportAllProfiles) {
// Export all schedules
exportData.data.schedules = appState.profiles;
} else {
// Export only active schedule
const activeProfile = appState.profiles.find(p => p.id === appState.activeProfileId);
if (activeProfile) {
exportData.data.schedules = [activeProfile];
} else {
// Fallback: create schedule from current days
const now = new Date().toISOString();
exportData.data.schedules = [{
id: `profile-export-${Date.now()}`,
name: 'Exported Schedule',
days: appState.days,
createdAt: now,
modifiedAt: now
}];
}
}
} }
if (options.includeDiagramSettings) { if (options.includeDiagramSettings) {
@@ -190,23 +216,60 @@ export const validateImportData = (data: any): ImportValidationResult => {
const importData = data.data; const importData = data.data;
// Validate schedules // Validate schedules (current profile-based format)
if (importData.schedules !== undefined) { if (importData.schedules !== undefined) {
if (!Array.isArray(importData.schedules)) { if (!Array.isArray(importData.schedules)) {
result.errors.push('Schedules: Invalid format (expected array)'); result.errors.push('Schedules: Invalid format (expected array)');
result.isValid = false; result.isValid = false;
} else { } else {
// Check for required fields in schedules // Check for required fields in schedule profiles
importData.schedules.forEach((day: any, index: number) => { importData.schedules.forEach((profile: any, index: number) => {
if (!day.id || !Array.isArray(day.doses)) { if (!profile.id || !profile.name || !Array.isArray(profile.days)) {
result.warnings.push(`Schedule day ${index + 1}: Missing required fields`); result.warnings.push(`Schedule ${index + 1}: Missing required fields (id, name, or days)`);
result.hasMissingFields = true; result.hasMissingFields = true;
} }
day.doses?.forEach((dose: any, doseIndex: number) => { // Validate days within schedule
if (!dose.id || dose.time === undefined || dose.ldx === undefined) { profile.days?.forEach((day: any, dayIndex: number) => {
result.warnings.push(`Schedule day ${index + 1}, dose ${doseIndex + 1}: Missing required fields`); if (!day.id || !Array.isArray(day.doses)) {
result.warnings.push(`Schedule ${index + 1}, day ${dayIndex + 1}: Missing required fields`);
result.hasMissingFields = true; result.hasMissingFields = true;
} }
day.doses?.forEach((dose: any, doseIndex: number) => {
if (!dose.id || dose.time === undefined || dose.ldx === undefined) {
result.warnings.push(`Schedule ${index + 1}, day ${dayIndex + 1}, dose ${doseIndex + 1}: Missing required fields`);
result.hasMissingFields = true;
}
});
});
});
}
}
// Validate profiles (legacy backward-compat: treat old 'profiles' key as schedules)
if (importData.profiles !== undefined) {
result.warnings.push('Using legacy "profiles" key - please re-export with current version');
if (!Array.isArray(importData.profiles)) {
result.errors.push('Profiles: Invalid format (expected array)');
result.isValid = false;
} else {
// Check for required fields in profiles
importData.profiles.forEach((profile: any, index: number) => {
if (!profile.id || !profile.name || !Array.isArray(profile.days)) {
result.warnings.push(`Profile ${index + 1}: Missing required fields (id, name, or days)`);
result.hasMissingFields = true;
}
// Validate days within profile
profile.days?.forEach((day: any, dayIndex: number) => {
if (!day.id || !Array.isArray(day.doses)) {
result.warnings.push(`Profile ${index + 1}, day ${dayIndex + 1}: Missing required fields`);
result.hasMissingFields = true;
}
day.doses?.forEach((dose: any, doseIndex: number) => {
if (!dose.id || dose.time === undefined || dose.ldx === undefined) {
result.warnings.push(`Profile ${index + 1}, day ${dayIndex + 1}, dose ${doseIndex + 1}: Missing required fields`);
result.hasMissingFields = true;
}
});
}); });
}); });
} }
@@ -267,28 +330,121 @@ export const validateImportData = (data: any): ImportValidationResult => {
return result; return result;
}; };
/**
* Resolve name conflicts by appending a numeric suffix
*/
const resolveProfileNameConflict = (name: string, existingNames: string[]): string => {
let finalName = name;
let suffix = 2;
const existingNamesLower = existingNames.map(n => n.toLowerCase());
while (existingNamesLower.includes(finalName.toLowerCase())) {
finalName = `${name} (${suffix})`;
suffix++;
}
return finalName;
};
/** /**
* Import validated data into app state * Import validated data into app state
*/ */
export const importSettings = ( export const importSettings = (
currentState: AppState, currentState: AppState,
importData: ExportData['data'], importData: ExportData['data'],
options: ExportOptions options: ExportOptions,
importOptions: ImportOptions = {}
): Partial<AppState> => { ): Partial<AppState> => {
const newState: Partial<AppState> = {}; const newState: Partial<AppState> = {};
if (options.includeSchedules && importData.schedules) { if (options.includeSchedules) {
newState.days = importData.schedules.map(day => ({ // Handle schedules (current profile-based format)
...day, if (importData.schedules && importData.schedules.length > 0) {
// Ensure all required fields exist const mergeMode = importOptions.mergeProfiles ?? false;
doses: day.doses.map(dose => ({
id: dose.id || `dose-${Date.now()}-${Math.random()}`, if (mergeMode) {
time: dose.time || '12:00', // Merge: add imported schedules to existing ones
ldx: dose.ldx || '0', const existingProfiles = currentState.profiles || [];
damph: dose.damph, const existingNames = existingProfiles.map(p => p.name);
isFed: dose.isFed, // Explicitly preserve food-timing flag
})) // Check if merge would exceed maximum schedules
})); if (existingProfiles.length + importData.schedules.length > MAX_PROFILES) {
throw new Error(`Cannot merge: Would exceed maximum of ${MAX_PROFILES} schedules. Please delete some schedules first.`);
}
// Process imported schedules
const now = new Date().toISOString();
const newProfiles = importData.schedules.map(profile => {
// Resolve name conflicts
const resolvedName = resolveProfileNameConflict(profile.name, existingNames);
existingNames.push(resolvedName); // Track for next iteration
return {
...profile,
id: `profile-import-${Date.now()}-${Math.random()}`, // New ID
name: resolvedName,
modifiedAt: now
};
});
newState.profiles = [...existingProfiles, ...newProfiles];
// Keep active profile unchanged
newState.activeProfileId = currentState.activeProfileId;
} else {
// Replace: overwrite all schedules
const now = new Date().toISOString();
newState.profiles = importData.schedules.map((profile, index) => ({
...profile,
id: `profile-import-${Date.now()}-${index}`, // Regenerate IDs
modifiedAt: now
}));
// Set first imported schedule as active
newState.activeProfileId = newState.profiles[0].id;
newState.days = newState.profiles[0].days;
}
}
// Handle legacy 'profiles' key (backward compatibility - renamed to schedules)
else if (importData.profiles && importData.profiles.length > 0) {
// Same logic as above but with legacy key
const mergeMode = importOptions.mergeProfiles ?? false;
if (mergeMode) {
const existingProfiles = currentState.profiles || [];
const existingNames = existingProfiles.map(p => p.name);
if (existingProfiles.length + importData.profiles.length > MAX_PROFILES) {
throw new Error(`Cannot merge: Would exceed maximum of ${MAX_PROFILES} schedules.`);
}
const now = new Date().toISOString();
const newProfiles = importData.profiles.map(profile => {
const resolvedName = resolveProfileNameConflict(profile.name, existingNames);
existingNames.push(resolvedName);
return {
...profile,
id: `profile-import-${Date.now()}-${Math.random()}`,
name: resolvedName,
modifiedAt: now
};
});
newState.profiles = [...existingProfiles, ...newProfiles];
newState.activeProfileId = currentState.activeProfileId;
} else {
const now = new Date().toISOString();
newState.profiles = importData.profiles.map((profile, index) => ({
...profile,
id: `profile-import-${Date.now()}-${index}`,
modifiedAt: now
}));
newState.activeProfileId = newState.profiles[0].id;
newState.days = newState.profiles[0].days;
}
}
} }
if (options.includeDiagramSettings && importData.diagramSettings) { if (options.includeDiagramSettings && importData.diagramSettings) {
@@ -375,18 +531,35 @@ export const deleteSelectedData = (
let shouldRemoveMainStorage = false; let shouldRemoveMainStorage = false;
if (options.includeSchedules) { if (options.includeSchedules) {
// Delete schedules - but always keep template day with at least one dose // Delete all profiles and optionally restore examples
// Never allow complete deletion as this breaks the app
const defaults = getDefaultState(); const defaults = getDefaultState();
newState.days = [ const now = new Date().toISOString();
{
id: 'day-template', if (options.restoreExamples) {
isTemplate: true, // Restore factory default example profiles
doses: [ newState.profiles = defaults.profiles;
{ id: 'dose-default', time: '06:00', ldx: '70' } newState.activeProfileId = defaults.activeProfileId;
] newState.days = defaults.days;
} } else {
]; // Create a single blank profile
newState.profiles = [{
id: `profile-blank-${Date.now()}`,
name: 'Default',
days: [
{
id: 'day-template',
isTemplate: true,
doses: [
{ id: 'dose-default', time: '08:00', ldx: '30' }
]
}
],
createdAt: now,
modifiedAt: now
}];
newState.activeProfileId = newState.profiles[0].id;
newState.days = newState.profiles[0].days;
}
shouldRemoveMainStorage = true; shouldRemoveMainStorage = true;
} }