Files
med-plan-assistant/src/hooks/useAppState.ts
Andreas Weyer b198164760 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.
2026-02-10 18:33:41 +00:00

535 lines
17 KiB
TypeScript

/**
* Application State Hook
*
* Manages global application state with localStorage persistence.
* Provides type-safe state updates for nested objects and automatic
* state saving on changes.
*
* @author Andreas Weyer
* @license MIT
*/
import React from 'react';
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<AppState>(getDefaultState);
const [isLoaded, setIsLoaded] = React.useState(false);
React.useEffect(() => {
try {
const savedState = window.localStorage.getItem(LOCAL_STORAGE_KEY);
if (savedState) {
const parsedState = JSON.parse(savedState);
const defaults = getDefaultState();
// Migrate old boolean showDayTimeOnXAxis to new string enum
let migratedUiSettings = {...defaults.uiSettings, ...parsedState.uiSettings};
if (typeof migratedUiSettings.showDayTimeOnXAxis === 'boolean') {
migratedUiSettings.showDayTimeOnXAxis = migratedUiSettings.showDayTimeOnXAxis ? '24h' : 'continuous';
}
// Migrate urinePh from old {enabled, phTendency} to new {mode} structure
let migratedPkParams = {...defaults.pkParams, ...parsedState.pkParams};
if (migratedPkParams.advanced) {
const oldUrinePh = migratedPkParams.advanced.urinePh as any;
if (oldUrinePh && typeof oldUrinePh === 'object' && 'enabled' in oldUrinePh) {
// Old format detected: {enabled: boolean, phTendency: string}
if (!oldUrinePh.enabled) {
migratedPkParams.advanced.urinePh = { mode: 'normal' };
} else {
const phValue = parseFloat(oldUrinePh.phTendency);
if (!isNaN(phValue)) {
if (phValue < 6.0) {
migratedPkParams.advanced.urinePh = { mode: 'acidic' };
} else if (phValue > 7.5) {
migratedPkParams.advanced.urinePh = { mode: 'alkaline' };
} else {
migratedPkParams.advanced.urinePh = { mode: 'normal' };
}
} else {
migratedPkParams.advanced.urinePh = { mode: 'normal' };
}
}
}
// Migrate weightBasedVd from old {enabled, bodyWeight} to new standardVd structure
const oldWeightBasedVd = (migratedPkParams.advanced as any).weightBasedVd;
if (oldWeightBasedVd && typeof oldWeightBasedVd === 'object' && 'enabled' in oldWeightBasedVd) {
// Old format detected: {enabled: boolean, bodyWeight: string}
if (oldWeightBasedVd.enabled) {
// Convert to new weight-based preset
migratedPkParams.advanced.standardVd = {
preset: 'weight-based',
customValue: migratedPkParams.advanced.standardVd?.customValue || '377',
bodyWeight: oldWeightBasedVd.bodyWeight || '70'
};
} else {
// Keep existing standardVd, but ensure bodyWeight is present
if (!migratedPkParams.advanced.standardVd?.bodyWeight) {
migratedPkParams.advanced.standardVd = {
...migratedPkParams.advanced.standardVd,
bodyWeight: oldWeightBasedVd.bodyWeight || '70'
};
}
}
// Remove old weightBasedVd property
delete (migratedPkParams.advanced as any).weightBasedVd;
}
// Ensure bodyWeight exists in standardVd (for new installations or old formats)
if (!migratedPkParams.advanced.standardVd?.bodyWeight) {
migratedPkParams.advanced.standardVd = {
...migratedPkParams.advanced.standardVd,
bodyWeight: '70'
};
}
}
// Validate numeric fields and replace empty/invalid values with defaults
const validateNumericField = (value: any, defaultValue: any): any => {
if (value === '' || value === null || value === undefined || isNaN(Number(value))) {
return defaultValue;
}
return value;
};
// 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;
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: migratedDays,
profiles: migratedProfiles,
activeProfileId: migratedActiveProfileId,
uiSettings: migratedUiSettings,
});
}
} catch (error) {
console.error("Failed to load state", error);
}
setIsLoaded(true);
}, []);
React.useEffect(() => {
if (isLoaded) {
try {
const stateToSave = {
pkParams: appState.pkParams,
days: appState.days,
profiles: appState.profiles,
activeProfileId: appState.activeProfileId,
steadyStateConfig: appState.steadyStateConfig,
therapeuticRange: appState.therapeuticRange,
doseIncrement: appState.doseIncrement,
uiSettings: appState.uiSettings,
};
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(stateToSave));
} catch (error) {
console.error("Failed to save state", error);
}
}
}, [appState, isLoaded]);
const updateState = <K extends keyof AppState>(key: K, value: AppState[K]) => {
setAppState(prev => ({ ...prev, [key]: value }));
};
const updateNestedState = <P extends keyof AppState>(
parentKey: P,
childKey: string,
value: any
) => {
setAppState(prev => ({
...prev,
[parentKey]: { ...(prev[parentKey] as any), [childKey]: value }
}));
};
const updateUiSetting = <K extends keyof AppState['uiSettings']>(
key: K,
value: AppState['uiSettings'][K]
) => {
setAppState(prev => {
const newUiSettings = { ...prev.uiSettings, [key]: value };
// Auto-adjust displayedDays if simulationDays is reduced
if (key === 'simulationDays') {
const simDays = parseInt(value as string, 10) || 3;
const dispDays = parseInt(prev.uiSettings.displayedDays, 10) || 2;
if (dispDays > simDays) {
newUiSettings.displayedDays = String(simDays);
}
}
return { ...prev, uiSettings: newUiSettings };
});
};
// Day management functions
const addDay = (cloneFromDayId?: string) => {
const maxDays = 3; // Template + 2 deviation days
if (appState.days.length >= maxDays) return;
const sourceDay = cloneFromDayId
? appState.days.find(d => d.id === cloneFromDayId)
: undefined;
const newDay: DayGroup = sourceDay
? {
id: `day-${Date.now()}`,
isTemplate: false,
doses: sourceDay.doses.map(d => ({
id: `dose-${Date.now()}-${Math.random()}`,
time: d.time,
ldx: d.ldx
}))
}
: {
id: `day-${Date.now()}`,
isTemplate: false,
doses: [{ id: `dose-${Date.now()}`, time: '12:00', ldx: '30' }]
};
setAppState(prev => ({ ...prev, days: [...prev.days, newDay] }));
};
const removeDay = (dayId: string) => {
setAppState(prev => {
const dayToRemove = prev.days.find(d => d.id === dayId);
// Never delete template day
if (dayToRemove?.isTemplate) {
console.warn('Cannot delete template day');
return prev;
}
// Never delete if it would leave us with no days
if (prev.days.length <= 1) {
console.warn('Cannot delete last day');
return prev;
}
return { ...prev, days: prev.days.filter(d => d.id !== dayId) };
});
};
const updateDay = (dayId: string, updatedDay: DayGroup) => {
setAppState(prev => ({
...prev,
days: prev.days.map(day => day.id === dayId ? updatedDay : day)
}));
};
const addDoseToDay = (dayId: string, newDose?: Partial<DayDose>) => {
setAppState(prev => ({
...prev,
days: prev.days.map(day => {
if (day.id !== dayId) return day;
if (day.doses.length >= MAX_DOSES_PER_DAY) return day; // Max doses per day
// Calculate dynamic default time: max time + 1 hour, capped at 23:59
let defaultTime = '12:00';
if (!newDose?.time && day.doses.length > 0) {
// Find the latest time in the day
const times = day.doses.map(d => d.time || '00:00');
const maxTime = times.reduce((max, time) => time > max ? time : max, '00:00');
// Parse and add 1 hour
const [hours, minutes] = maxTime.split(':').map(Number);
let newHours = hours + 1;
// Cap at 23:59
if (newHours > 23) {
newHours = 23;
defaultTime = '23:59';
} else {
defaultTime = `${newHours.toString().padStart(2, '0')}:00`;
}
}
const dose: DayDose = {
id: `dose-${Date.now()}-${Math.random()}`,
time: newDose?.time || defaultTime,
ldx: newDose?.ldx || '0',
damph: newDose?.damph || '0',
isFed: newDose?.isFed || false,
};
return { ...day, doses: [...day.doses, dose] };
})
}));
};
const removeDoseFromDay = (dayId: string, doseId: string) => {
setAppState(prev => ({
...prev,
days: prev.days.map(day => {
if (day.id !== dayId) return day;
// Don't allow removing last dose from template day
if (day.isTemplate && day.doses.length <= 1) return day;
return { ...day, doses: day.doses.filter(dose => dose.id !== doseId) };
})
}));
};
const updateDoseInDay = (dayId: string, doseId: string, field: keyof DayDose, value: string) => {
setAppState(prev => ({
...prev,
days: prev.days.map(day => {
if (day.id !== dayId) return day;
// Update the dose field (no auto-sort)
const updatedDoses = day.doses.map(dose =>
dose.id === doseId ? { ...dose, [field]: value } : dose
);
return {
...day,
doses: updatedDoses
};
})
}));
};
// More flexible update function for non-string fields (e.g., isFed boolean)
const updateDoseFieldInDay = (dayId: string, doseId: string, field: string, value: any) => {
setAppState(prev => ({
...prev,
days: prev.days.map(day => {
if (day.id !== dayId) return day;
const updatedDoses = day.doses.map(dose =>
dose.id === doseId ? { ...dose, [field]: value } : dose
);
return {
...day,
doses: updatedDoses
};
})
}));
};
const sortDosesInDay = (dayId: string) => {
setAppState(prev => ({
...prev,
days: prev.days.map(day => {
if (day.id !== dayId) return day;
const sortedDoses = [...day.doses].sort((a, b) => {
const timeA = a.time || '00:00';
const timeB = b.time || '00:00';
return timeA.localeCompare(timeB);
});
return {
...day,
doses: sortedDoses
};
})
}));
};
// 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,
updateState,
updateNestedState,
updateUiSetting,
addDay,
removeDay,
updateDay,
addDoseToDay,
removeDoseFromDay,
updateDoseInDay,
updateDoseFieldInDay,
sortDosesInDay,
// Profile management
getActiveProfile,
createProfile,
deleteProfile,
switchProfile,
saveProfile,
saveProfileAs,
updateProfileName,
hasUnsavedChanges
};
};