- 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.
535 lines
17 KiB
TypeScript
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
|
|
};
|
|
};
|