/** * 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(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 = (key: K, value: AppState[K]) => { setAppState(prev => ({ ...prev, [key]: value })); }; const updateNestedState =

( parentKey: P, childKey: string, value: any ) => { setAppState(prev => ({ ...prev, [parentKey]: { ...(prev[parentKey] as any), [childKey]: value } })); }; const updateUiSetting = ( 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) => { 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 }; };