995 lines
32 KiB
TypeScript
995 lines
32 KiB
TypeScript
import React, {
|
|
useState,
|
|
useEffect,
|
|
useMemo,
|
|
useCallback,
|
|
useRef,
|
|
} from 'react';
|
|
import { generateSchedule, generateReminderSchedule } from './utils/schedule';
|
|
import { getAppConfig } from './utils/env';
|
|
import {
|
|
Medication,
|
|
Dose,
|
|
DoseStatus,
|
|
HistoricalDose,
|
|
User,
|
|
UserSettings,
|
|
TakenDoses,
|
|
CustomReminder,
|
|
ScheduleItem,
|
|
DailyStat,
|
|
MedicationStat,
|
|
} from './types';
|
|
|
|
// Component imports - organized by feature
|
|
import {
|
|
AddMedicationModal,
|
|
EditMedicationModal,
|
|
ManageMedicationsModal,
|
|
DoseCard,
|
|
} from './components/medication';
|
|
import {
|
|
AuthPage,
|
|
AvatarDropdown,
|
|
ChangePasswordModal,
|
|
ResetPasswordPage,
|
|
} from './components/auth';
|
|
import { AdminInterface } from './components/admin';
|
|
import {
|
|
AccountModal,
|
|
AddReminderModal,
|
|
EditReminderModal,
|
|
HistoryModal,
|
|
ManageRemindersModal,
|
|
OnboardingModal,
|
|
StatsModal,
|
|
} from './components/modals';
|
|
import { ReminderCard, ThemeSwitcher } from './components/ui';
|
|
|
|
// Icon and utility imports
|
|
import {
|
|
PillIcon,
|
|
PlusIcon,
|
|
MenuIcon,
|
|
HistoryIcon,
|
|
SunIcon,
|
|
SunsetIcon,
|
|
MoonIcon,
|
|
SearchIcon,
|
|
SettingsIcon,
|
|
BellIcon,
|
|
BarChartIcon,
|
|
} from './components/icons/Icons';
|
|
import { useUser } from './contexts/UserContext';
|
|
import { databaseService } from './services/database';
|
|
import { databaseSeeder } from './services/database.seeder';
|
|
import { logger } from './services/logging';
|
|
import { normalizeError } from './utils/error';
|
|
import { determineDoseStatus } from './utils/doseStatus';
|
|
|
|
const Header: React.FC<{
|
|
onAdd: () => void;
|
|
onManage: () => void;
|
|
onManageReminders: () => void;
|
|
onHistory: () => void;
|
|
onStats: () => void;
|
|
onAccount: () => void;
|
|
onAdmin: () => void;
|
|
onChangePassword: () => void;
|
|
user: User;
|
|
onLogout: () => void;
|
|
}> = ({
|
|
onAdd,
|
|
onManage,
|
|
onManageReminders,
|
|
onHistory,
|
|
onStats,
|
|
onAccount,
|
|
onAdmin,
|
|
onChangePassword,
|
|
user,
|
|
onLogout,
|
|
}) => (
|
|
<header className='bg-white dark:bg-slate-800 shadow-md sticky top-0 z-20 border-b border-slate-200 dark:border-slate-700'>
|
|
<div className='container mx-auto px-4 py-3 flex justify-between items-center'>
|
|
<div className='flex items-center space-x-3'>
|
|
<div className='bg-indigo-600 p-2 rounded-lg'>
|
|
<PillIcon className='w-6 h-6 text-white' />
|
|
</div>
|
|
<h1 className='text-xl md:text-2xl font-bold text-slate-800 dark:text-slate-100'>
|
|
Medication Reminder
|
|
</h1>
|
|
</div>
|
|
<div className='flex items-center space-x-2'>
|
|
<button
|
|
onClick={onStats}
|
|
className='hidden sm:flex items-center space-x-2 px-4 py-2 text-sm font-medium text-slate-700 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600'
|
|
>
|
|
<BarChartIcon className='w-4 h-4' aria-hidden='true' />
|
|
<span>Stats</span>
|
|
</button>
|
|
<button
|
|
onClick={onHistory}
|
|
className='hidden sm:flex items-center space-x-2 px-4 py-2 text-sm font-medium text-slate-700 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600'
|
|
>
|
|
<HistoryIcon className='w-4 h-4' aria-hidden='true' />
|
|
<span>History</span>
|
|
</button>
|
|
<button
|
|
onClick={onManage}
|
|
className='hidden sm:flex items-center space-x-2 px-4 py-2 text-sm font-medium text-slate-700 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600'
|
|
>
|
|
<MenuIcon className='w-4 h-4' aria-hidden='true' />
|
|
<span>{getAppConfig().name}</span>
|
|
</button>
|
|
<button
|
|
onClick={onManageReminders}
|
|
className='flex items-center space-x-2 px-4 py-2 text-sm font-medium text-slate-700 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600'
|
|
>
|
|
<BellIcon className='w-4 h-4' aria-hidden='true' />
|
|
<span className='hidden sm:inline'>Reminders</span>
|
|
</button>
|
|
<button
|
|
onClick={onAdd}
|
|
className='flex items-center space-x-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-slate-900'
|
|
>
|
|
<PlusIcon className='w-4 h-4' aria-hidden='true' />
|
|
<span className='hidden sm:inline'>Add Med</span>
|
|
</button>
|
|
<div className='border-l border-slate-200 dark:border-slate-600 h-8 mx-2'></div>
|
|
<ThemeSwitcher />
|
|
<button
|
|
onClick={onAccount}
|
|
className='flex items-center justify-center w-10 h-10 rounded-lg bg-slate-100 hover:bg-slate-200 dark:bg-slate-700 dark:hover:bg-slate-600 transition-colors'
|
|
aria-label='Account settings'
|
|
>
|
|
<SettingsIcon className='w-5 h-5 text-slate-700 dark:text-slate-200' />
|
|
</button>
|
|
<div className='border-l border-slate-200 dark:border-slate-600 h-8 ml-2'></div>
|
|
<AvatarDropdown
|
|
user={user}
|
|
onLogout={onLogout}
|
|
onAdmin={onAdmin}
|
|
onChangePassword={onChangePassword}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
|
|
const EmptyState: React.FC<{ onAdd: () => void }> = ({ onAdd }) => (
|
|
<div className='text-center py-20 px-4'>
|
|
<div className='mx-auto bg-slate-200 dark:bg-slate-700 rounded-full h-16 w-16 flex items-center justify-center animate-float'>
|
|
<PillIcon className='w-8 h-8 text-slate-500 dark:text-slate-400' />
|
|
</div>
|
|
<h3 className='mt-4 text-lg font-semibold text-slate-800 dark:text-slate-100'>
|
|
No Medications Scheduled
|
|
</h3>
|
|
<p className='mt-1 text-slate-500 dark:text-slate-400'>
|
|
Get started by adding your first medication.
|
|
</p>
|
|
<div className='mt-6'>
|
|
<button
|
|
type='button'
|
|
onClick={onAdd}
|
|
className='inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-slate-900'
|
|
>
|
|
<PlusIcon className='-ml-1 mr-2 h-5 w-5' />
|
|
Add Medication
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const groupDetails: {
|
|
[key: string]: {
|
|
icon: React.FC<React.SVGProps<SVGSVGElement>>;
|
|
iconClass: string;
|
|
};
|
|
} = {
|
|
Morning: { icon: SunIcon, iconClass: 'text-amber-500' },
|
|
Afternoon: { icon: SunIcon, iconClass: 'text-sky-500' },
|
|
Evening: {
|
|
icon: SunsetIcon,
|
|
iconClass: 'text-indigo-500 dark:text-indigo-400',
|
|
},
|
|
Night: { icon: MoonIcon, iconClass: 'text-slate-500 dark:text-slate-400' },
|
|
};
|
|
|
|
const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|
const { logout, updateUser } = useUser();
|
|
|
|
const [medications, setMedications] = useState<Medication[]>([]);
|
|
const [customReminders, setCustomReminders] = useState<CustomReminder[]>([]);
|
|
const [takenDosesDoc, setTakenDosesDoc] = useState<TakenDoses | null>(null);
|
|
const [settings, setSettings] = useState<UserSettings | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const [currentTime, setCurrentTime] = useState(() => new Date());
|
|
const [isAddModalOpen, setAddModalOpen] = useState(false);
|
|
const [isManageModalOpen, setManageModalOpen] = useState(false);
|
|
const [isHistoryModalOpen, setHistoryModalOpen] = useState(false);
|
|
const [isAccountModalOpen, setAccountModalOpen] = useState(false);
|
|
const [isStatsModalOpen, setStatsModalOpen] = useState(false);
|
|
const [editingMedication, setEditingMedication] = useState<Medication | null>(
|
|
null
|
|
);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [isOnboardingOpen, setOnboardingOpen] = useState(false);
|
|
const [isAdminInterfaceOpen, setAdminInterfaceOpen] = useState(false);
|
|
const [isChangePasswordOpen, setChangePasswordOpen] = useState(false);
|
|
|
|
const [isManageRemindersOpen, setManageRemindersOpen] = useState(false);
|
|
const [isAddReminderOpen, setAddReminderOpen] = useState(false);
|
|
const [editingReminder, setEditingReminder] = useState<CustomReminder | null>(
|
|
null
|
|
);
|
|
const [snoozedDoses, setSnoozedDoses] = useState<Record<string, string>>({});
|
|
|
|
const notificationTimers = useRef<Record<string, number>>({});
|
|
|
|
const takenDoses = useMemo(() => takenDosesDoc?.doses ?? {}, [takenDosesDoc]);
|
|
|
|
useEffect(() => {
|
|
// Don't try to fetch data if user._id is not available
|
|
if (!user._id) {
|
|
logger.ui.action('Skipping data fetch because user id is missing', {
|
|
userId: user._id,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
logger.db.query('Fetching medication data for user', {
|
|
userId: user._id,
|
|
});
|
|
|
|
const [medsData, remindersData, takenDosesData, settingsData] =
|
|
await Promise.all([
|
|
databaseService.getMedications(user._id),
|
|
databaseService.getCustomReminders(user._id),
|
|
databaseService.getTakenDoses(user._id),
|
|
databaseService.getUserSettings(user._id),
|
|
]);
|
|
|
|
logger.db.query('Fetched user data successfully', {
|
|
userId: user._id,
|
|
medicationCount: medsData.length,
|
|
reminderCount: remindersData.length,
|
|
hasTakenDoses: !!takenDosesData,
|
|
hasSettings: !!settingsData,
|
|
});
|
|
|
|
setMedications(medsData);
|
|
setCustomReminders(remindersData);
|
|
setTakenDosesDoc(takenDosesData);
|
|
setSettings(settingsData);
|
|
|
|
if (!settingsData.hasCompletedOnboarding) {
|
|
setOnboardingOpen(true);
|
|
}
|
|
} catch (e) {
|
|
setError('Failed to load your data. Please try again.');
|
|
logger.db.error('Error loading user data', normalizeError(e), {
|
|
userId: user._id,
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Add a small delay to ensure user state is fully settled
|
|
const timeoutId = setTimeout(() => {
|
|
fetchData();
|
|
}, 100);
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}, [user._id, user]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
settings?.notificationsEnabled &&
|
|
'Notification' in window &&
|
|
Notification.permission === 'default'
|
|
) {
|
|
Notification.requestPermission();
|
|
}
|
|
}, [settings?.notificationsEnabled]);
|
|
|
|
useEffect(() => {
|
|
const timer = setInterval(() => setCurrentTime(new Date()), 60000);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
const unifiedSchedule = useMemo(() => {
|
|
const medSchedule = generateSchedule(medications, currentTime);
|
|
const reminderSchedule = generateReminderSchedule(
|
|
customReminders,
|
|
currentTime
|
|
);
|
|
const combined = [...medSchedule, ...reminderSchedule] as ScheduleItem[];
|
|
return combined.sort(
|
|
(a, b) => a.scheduledTime.getTime() - b.scheduledTime.getTime()
|
|
);
|
|
}, [medications, customReminders, currentTime]);
|
|
|
|
const handleAddMedication = async (med: Omit<Medication, '_id' | '_rev'>) => {
|
|
const newMed = await databaseService.createMedication(user._id, med);
|
|
setMedications(prev => [...prev, newMed]);
|
|
setAddModalOpen(false);
|
|
};
|
|
|
|
const handleDeleteMedication = async (medToDelete: Medication) => {
|
|
await databaseService.deleteMedication(medToDelete._id);
|
|
setMedications(meds => meds.filter(med => med._id !== medToDelete._id));
|
|
};
|
|
|
|
const handleUpdateMedication = async (updatedMed: Medication) => {
|
|
const savedMed = await databaseService.updateMedication(updatedMed);
|
|
setMedications(meds =>
|
|
meds.map(m => (m._id === savedMed._id ? savedMed : m))
|
|
);
|
|
setEditingMedication(null);
|
|
};
|
|
|
|
const handleAddReminder = async (
|
|
reminder: Omit<CustomReminder, '_id' | '_rev'>
|
|
) => {
|
|
const newReminder = await databaseService.createCustomReminder(
|
|
user._id,
|
|
reminder
|
|
);
|
|
setCustomReminders(prev => [...prev, newReminder]);
|
|
setAddReminderOpen(false);
|
|
};
|
|
|
|
const handleUpdateReminder = async (updatedReminder: CustomReminder) => {
|
|
const savedReminder =
|
|
await databaseService.updateCustomReminder(updatedReminder);
|
|
setCustomReminders(reminders =>
|
|
reminders.map(r => (r._id === savedReminder._id ? savedReminder : r))
|
|
);
|
|
setEditingReminder(null);
|
|
};
|
|
|
|
const handleDeleteReminder = async (reminderToDelete: CustomReminder) => {
|
|
await databaseService.deleteCustomReminder(reminderToDelete._id);
|
|
setCustomReminders(reminders =>
|
|
reminders.filter(r => r._id !== reminderToDelete._id)
|
|
);
|
|
};
|
|
|
|
const handleOpenEditModal = (med: Medication) => {
|
|
setEditingMedication(med);
|
|
setManageModalOpen(false);
|
|
};
|
|
|
|
const handleOpenEditReminderModal = (reminder: CustomReminder) => {
|
|
setEditingReminder(reminder);
|
|
setManageRemindersOpen(false);
|
|
};
|
|
|
|
const handleToggleDose = useCallback(
|
|
async (doseId: string) => {
|
|
if (!takenDosesDoc) return;
|
|
const newDoses = { ...takenDosesDoc.doses };
|
|
const wasTaken = Boolean(newDoses[doseId]);
|
|
|
|
if (wasTaken) {
|
|
delete newDoses[doseId];
|
|
} else {
|
|
newDoses[doseId] = new Date().toISOString();
|
|
}
|
|
|
|
const updatedDoc = await databaseService.updateTakenDoses({
|
|
...takenDosesDoc,
|
|
doses: newDoses,
|
|
});
|
|
setTakenDosesDoc(updatedDoc);
|
|
|
|
if (!wasTaken) {
|
|
setSnoozedDoses(prev => {
|
|
if (!prev[doseId]) {
|
|
return prev;
|
|
}
|
|
const updated = { ...prev };
|
|
delete updated[doseId];
|
|
return updated;
|
|
});
|
|
|
|
if (notificationTimers.current[doseId]) {
|
|
clearTimeout(notificationTimers.current[doseId]);
|
|
delete notificationTimers.current[doseId];
|
|
}
|
|
}
|
|
},
|
|
[takenDosesDoc]
|
|
);
|
|
|
|
const handleSnoozeDose = useCallback((doseId: string) => {
|
|
const SNOOZE_DURATION = 5 * 60 * 1000; // 5 minutes
|
|
const snoozedUntil = new Date(Date.now() + SNOOZE_DURATION).toISOString();
|
|
setSnoozedDoses(prev => ({ ...prev, [doseId]: snoozedUntil }));
|
|
|
|
// Clear existing timer and set a new one
|
|
if (notificationTimers.current[doseId]) {
|
|
clearTimeout(notificationTimers.current[doseId]);
|
|
delete notificationTimers.current[doseId];
|
|
}
|
|
}, []);
|
|
|
|
const getDoseStatus = useCallback(
|
|
(dose: Dose, doseTime: Date, now: Date): DoseStatus =>
|
|
determineDoseStatus({
|
|
takenAt: takenDoses[dose.id],
|
|
snoozedUntil: snoozedDoses[dose.id],
|
|
scheduledTime: doseTime,
|
|
now,
|
|
}),
|
|
[takenDoses, snoozedDoses]
|
|
);
|
|
|
|
const scheduleWithStatus = useMemo(() => {
|
|
return unifiedSchedule
|
|
.map(item => {
|
|
if ('medicationId' in item) {
|
|
// It's a Dose
|
|
const medication = medications.find(m => m._id === item.medicationId);
|
|
if (!medication) return null;
|
|
|
|
const snoozeString = snoozedDoses[item.id];
|
|
const snoozeDate = snoozeString ? new Date(snoozeString) : undefined;
|
|
const validSnooze =
|
|
snoozeDate && !Number.isNaN(snoozeDate.getTime())
|
|
? snoozeDate
|
|
: undefined;
|
|
|
|
return {
|
|
...item,
|
|
type: 'dose' as const,
|
|
medication,
|
|
status: getDoseStatus(item, item.scheduledTime, currentTime),
|
|
takenAt: takenDoses[item.id],
|
|
snoozedUntil: validSnooze,
|
|
};
|
|
} else {
|
|
// It's a Custom Reminder
|
|
return {
|
|
...item,
|
|
type: 'reminder' as const,
|
|
};
|
|
}
|
|
})
|
|
.filter((d): d is NonNullable<typeof d> => d !== null);
|
|
}, [
|
|
unifiedSchedule,
|
|
medications,
|
|
getDoseStatus,
|
|
currentTime,
|
|
takenDoses,
|
|
snoozedDoses,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!settings?.notificationsEnabled ||
|
|
!('Notification' in window) ||
|
|
Notification.permission !== 'granted'
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const now = new Date();
|
|
const activeTimers = notificationTimers.current;
|
|
|
|
scheduleWithStatus.forEach(item => {
|
|
const itemId = item.id;
|
|
if (activeTimers[itemId]) return; // Timer already set
|
|
|
|
let timeToNotification = -1;
|
|
let notificationBody = '';
|
|
let notificationTitle = '';
|
|
let targetTime: Date | null = null;
|
|
|
|
if (item.type === 'dose' && item.status === DoseStatus.UPCOMING) {
|
|
targetTime = item.snoozedUntil ?? item.scheduledTime;
|
|
notificationTitle = 'Time for your medication!';
|
|
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
|
|
} else if (
|
|
item.type === 'dose' &&
|
|
item.status === DoseStatus.SNOOZED &&
|
|
item.snoozedUntil
|
|
) {
|
|
targetTime = item.snoozedUntil;
|
|
notificationTitle = 'Snoozed Medication Reminder';
|
|
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
|
|
} else if (item.type === 'reminder' && item.scheduledTime > now) {
|
|
targetTime = item.scheduledTime;
|
|
notificationTitle = 'Reminder';
|
|
notificationBody = item.title;
|
|
}
|
|
|
|
if (targetTime) {
|
|
timeToNotification = targetTime.getTime() - now.getTime();
|
|
}
|
|
|
|
if (timeToNotification > 0) {
|
|
const timerId = window.setTimeout(() => {
|
|
new Notification(notificationTitle, {
|
|
body: notificationBody,
|
|
tag: itemId,
|
|
});
|
|
if (item.type === 'dose' && item.status === DoseStatus.SNOOZED) {
|
|
setSnoozedDoses(prev => {
|
|
const newSnoozed = { ...prev };
|
|
newSnoozed[itemId] = new Date().toISOString();
|
|
return newSnoozed;
|
|
});
|
|
}
|
|
delete activeTimers[itemId];
|
|
}, timeToNotification);
|
|
|
|
activeTimers[itemId] = timerId;
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
Object.entries(activeTimers).forEach(([id, timer]) => {
|
|
clearTimeout(timer);
|
|
delete activeTimers[id];
|
|
});
|
|
};
|
|
}, [scheduleWithStatus, settings?.notificationsEnabled]);
|
|
|
|
const filteredSchedule = useMemo(
|
|
() =>
|
|
scheduleWithStatus.filter(item => {
|
|
if (item.type === 'reminder') {
|
|
return item.title.toLowerCase().includes(searchQuery.toLowerCase());
|
|
}
|
|
return item.medication.name
|
|
.toLowerCase()
|
|
.includes(searchQuery.toLowerCase());
|
|
}),
|
|
[scheduleWithStatus, searchQuery]
|
|
);
|
|
|
|
const groupedSchedule = useMemo(() => {
|
|
const groups: { [key: string]: typeof filteredSchedule } = {
|
|
Morning: [],
|
|
Afternoon: [],
|
|
Evening: [],
|
|
Night: [],
|
|
};
|
|
filteredSchedule.forEach(item => {
|
|
const hour = item.scheduledTime.getHours();
|
|
if (hour >= 5 && hour < 12) groups['Morning'].push(item);
|
|
else if (hour >= 12 && hour < 17) groups['Afternoon'].push(item);
|
|
else if (hour >= 17 && hour < 21) groups['Evening'].push(item);
|
|
else groups['Night'].push(item);
|
|
});
|
|
return groups;
|
|
}, [filteredSchedule]);
|
|
|
|
const medicationHistory = useMemo(() => {
|
|
const history: { date: string; doses: HistoricalDose[] }[] = [];
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const now = new Date();
|
|
for (let i = 0; i < 7; i++) {
|
|
const date = new Date(today);
|
|
date.setDate(date.getDate() - i);
|
|
const daySchedule = generateSchedule(medications, date);
|
|
if (daySchedule.length === 0 || date.getTime() > now.getTime()) continue;
|
|
const dosesForDay: HistoricalDose[] = daySchedule
|
|
.map(dose => {
|
|
const medication = medications.find(m => m._id === dose.medicationId);
|
|
return medication
|
|
? {
|
|
id: dose.id,
|
|
medication,
|
|
scheduledTime: dose.scheduledTime,
|
|
status: getDoseStatus(dose, dose.scheduledTime, now),
|
|
takenAt: takenDoses[dose.id],
|
|
}
|
|
: null;
|
|
})
|
|
.filter((d): d is NonNullable<typeof d> => d !== null);
|
|
if (dosesForDay.length > 0)
|
|
history.push({
|
|
date: date.toISOString().split('T')[0],
|
|
doses: dosesForDay,
|
|
});
|
|
}
|
|
return history;
|
|
}, [medications, takenDoses, getDoseStatus]);
|
|
|
|
const { dailyStats, medicationStats } = useMemo(() => {
|
|
const today = new Date();
|
|
today.setHours(23, 59, 59, 999);
|
|
const now = new Date();
|
|
|
|
const daily: DailyStat[] = [];
|
|
for (let i = 6; i >= 0; i--) {
|
|
const date = new Date();
|
|
date.setHours(0, 0, 0, 0);
|
|
date.setDate(date.getDate() - i);
|
|
|
|
const daySchedule = generateSchedule(medications, date);
|
|
const pastDoses = daySchedule.filter(d => d.scheduledTime < now);
|
|
|
|
if (pastDoses.length === 0) {
|
|
daily.push({ date: date.toISOString().split('T')[0], adherence: 100 });
|
|
continue;
|
|
}
|
|
|
|
let takenCount = 0;
|
|
pastDoses.forEach(dose => {
|
|
if (takenDoses[dose.id]) {
|
|
takenCount++;
|
|
}
|
|
});
|
|
|
|
const adherence = (takenCount / pastDoses.length) * 100;
|
|
daily.push({
|
|
date: date.toISOString().split('T')[0],
|
|
adherence: Math.round(adherence),
|
|
});
|
|
}
|
|
|
|
const statsByMedId: Record<
|
|
string,
|
|
{
|
|
taken: number;
|
|
missed: number;
|
|
upcoming: number;
|
|
medication: Medication;
|
|
lastTakenAt?: string;
|
|
}
|
|
> = {};
|
|
medications.forEach(med => {
|
|
statsByMedId[med._id] = {
|
|
taken: 0,
|
|
missed: 0,
|
|
upcoming: 0,
|
|
medication: med,
|
|
};
|
|
});
|
|
|
|
const sevenDaysAgo = new Date();
|
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
|
|
medications.forEach(med => {
|
|
for (let i = 0; i < 7; i++) {
|
|
const date = new Date();
|
|
date.setDate(date.getDate() - i);
|
|
const daySchedule = generateSchedule([med], date);
|
|
|
|
daySchedule.forEach(dose => {
|
|
const stat = statsByMedId[dose.medicationId];
|
|
if (stat) {
|
|
const status = getDoseStatus(dose, dose.scheduledTime, now);
|
|
if (status === DoseStatus.TAKEN) {
|
|
stat.taken++;
|
|
const takenAt = takenDoses[dose.id];
|
|
if (
|
|
takenAt &&
|
|
(!stat.lastTakenAt ||
|
|
new Date(takenAt) > new Date(stat.lastTakenAt))
|
|
) {
|
|
stat.lastTakenAt = takenAt;
|
|
}
|
|
} else if (status === DoseStatus.MISSED) stat.missed++;
|
|
else if (status === DoseStatus.UPCOMING) stat.upcoming++;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
const medication: MedicationStat[] = Object.values(statsByMedId)
|
|
.map(stat => {
|
|
const totalPast = stat.taken + stat.missed;
|
|
const adherence =
|
|
totalPast > 0 ? Math.round((stat.taken / totalPast) * 100) : 100;
|
|
return { ...stat, adherence };
|
|
})
|
|
.sort((a, b) => a.medication.name.localeCompare(b.medication.name));
|
|
|
|
return { dailyStats: daily, medicationStats: medication };
|
|
}, [medications, takenDoses, getDoseStatus]);
|
|
|
|
const handleUpdateSettings = async (newSettings: UserSettings) => {
|
|
const updatedSettings =
|
|
await databaseService.updateUserSettings(newSettings);
|
|
setSettings(updatedSettings);
|
|
};
|
|
|
|
const handleDeleteAllData = async () => {
|
|
if (
|
|
window.confirm(
|
|
'Are you sure you want to delete all your medication data? This action cannot be undone.'
|
|
)
|
|
) {
|
|
await databaseService.deleteAllUserData(user._id);
|
|
setMedications([]);
|
|
setCustomReminders([]);
|
|
const updatedTakenDoses = await databaseService.getTakenDoses(user._id);
|
|
setTakenDosesDoc(updatedTakenDoses);
|
|
setAccountModalOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleCompleteOnboarding = async () => {
|
|
if (settings) {
|
|
try {
|
|
const updatedSettings = await databaseService.updateUserSettings({
|
|
...settings,
|
|
hasCompletedOnboarding: true,
|
|
});
|
|
setSettings(updatedSettings);
|
|
setOnboardingOpen(false);
|
|
} catch (error) {
|
|
logger.ui.error(
|
|
'Failed to update onboarding status',
|
|
normalizeError(error),
|
|
{ userId: user._id }
|
|
);
|
|
setOnboardingOpen(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className='min-h-screen flex items-center justify-center'>
|
|
<PillIcon className='w-12 h-12 text-indigo-500 animate-spin' />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className='min-h-screen flex items-center justify-center text-red-500'>
|
|
{error}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className='min-h-screen text-slate-800 dark:text-slate-200'>
|
|
<Header
|
|
onAdd={() => setAddModalOpen(true)}
|
|
onManage={() => setManageModalOpen(true)}
|
|
onManageReminders={() => setManageRemindersOpen(true)}
|
|
onHistory={() => setHistoryModalOpen(true)}
|
|
onStats={() => setStatsModalOpen(true)}
|
|
onAccount={() => setAccountModalOpen(true)}
|
|
onAdmin={() => setAdminInterfaceOpen(true)}
|
|
onChangePassword={() => setChangePasswordOpen(true)}
|
|
user={user}
|
|
onLogout={logout}
|
|
/>
|
|
|
|
<main className='container mx-auto p-4 md:p-6'>
|
|
<div className='flex items-center justify-between mb-6'>
|
|
<h2 className='text-2xl font-bold dark:text-slate-100'>
|
|
Today's Schedule
|
|
</h2>
|
|
<time className='font-medium text-slate-500 dark:text-slate-400'>
|
|
{currentTime.toLocaleDateString(undefined, {
|
|
weekday: 'long',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})}
|
|
</time>
|
|
</div>
|
|
|
|
{medications.length > 0 || customReminders.length > 0 ? (
|
|
<>
|
|
<div className='relative mb-6'>
|
|
<div className='pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3'>
|
|
<SearchIcon
|
|
className='h-5 w-5 text-slate-400'
|
|
aria-hidden='true'
|
|
/>
|
|
</div>
|
|
<input
|
|
type='search'
|
|
name='search'
|
|
id='search'
|
|
className='block w-full pl-10 pr-4 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white'
|
|
placeholder='Search schedule...'
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
{filteredSchedule.length > 0 ? (
|
|
<div className='space-y-8'>
|
|
{Object.entries(groupedSchedule).map(([groupName, items]) => {
|
|
const scheduleItems = items as typeof filteredSchedule;
|
|
if (scheduleItems.length === 0) return null;
|
|
const Icon = groupDetails[groupName]?.icon;
|
|
return (
|
|
<section key={groupName}>
|
|
<div className='flex items-center space-x-3 mb-3 pb-2 border-b border-slate-200 dark:border-slate-700'>
|
|
{Icon && (
|
|
<Icon
|
|
className={`w-6 h-6 ${groupDetails[groupName].iconClass}`}
|
|
aria-hidden='true'
|
|
/>
|
|
)}
|
|
<h3 className='text-lg font-semibold text-slate-600 dark:text-slate-300'>
|
|
{groupName}
|
|
</h3>
|
|
</div>
|
|
<ul className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
|
|
{scheduleItems.map(item =>
|
|
item.type === 'dose' ? (
|
|
<DoseCard
|
|
key={item.id}
|
|
dose={item}
|
|
medication={item.medication}
|
|
status={item.status}
|
|
onToggleDose={handleToggleDose}
|
|
onSnooze={handleSnoozeDose}
|
|
snoozedUntil={item.snoozedUntil}
|
|
/>
|
|
) : (
|
|
<ReminderCard key={item.id} reminder={item} />
|
|
)
|
|
)}
|
|
</ul>
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className='text-center py-10 px-4'>
|
|
<SearchIcon className='mx-auto h-12 w-12 text-slate-400' />
|
|
<h3 className='mt-2 text-sm font-semibold text-slate-900 dark:text-slate-100'>
|
|
No items found
|
|
</h3>
|
|
<p className='mt-1 text-sm text-slate-500 dark:text-slate-400'>
|
|
Your search for "{searchQuery}" did not match any items
|
|
scheduled for today.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<EmptyState onAdd={() => setAddModalOpen(true)} />
|
|
)}
|
|
</main>
|
|
|
|
<AddMedicationModal
|
|
isOpen={isAddModalOpen}
|
|
onClose={() => setAddModalOpen(false)}
|
|
onAdd={handleAddMedication}
|
|
/>
|
|
<ManageMedicationsModal
|
|
isOpen={isManageModalOpen}
|
|
onClose={() => setManageModalOpen(false)}
|
|
medications={medications}
|
|
onDelete={handleDeleteMedication}
|
|
onEdit={handleOpenEditModal}
|
|
/>
|
|
<EditMedicationModal
|
|
isOpen={editingMedication !== null}
|
|
onClose={() => setEditingMedication(null)}
|
|
medication={editingMedication}
|
|
onUpdate={handleUpdateMedication}
|
|
/>
|
|
<HistoryModal
|
|
isOpen={isHistoryModalOpen}
|
|
onClose={() => setHistoryModalOpen(false)}
|
|
history={medicationHistory}
|
|
/>
|
|
<StatsModal
|
|
isOpen={isStatsModalOpen}
|
|
onClose={() => setStatsModalOpen(false)}
|
|
dailyStats={dailyStats}
|
|
medicationStats={medicationStats}
|
|
/>
|
|
{settings && (
|
|
<AccountModal
|
|
isOpen={isAccountModalOpen}
|
|
onClose={() => setAccountModalOpen(false)}
|
|
user={user}
|
|
settings={settings}
|
|
onUpdateUser={updateUser}
|
|
onUpdateSettings={handleUpdateSettings}
|
|
onDeleteAllData={handleDeleteAllData}
|
|
/>
|
|
)}
|
|
<OnboardingModal
|
|
isOpen={isOnboardingOpen}
|
|
onComplete={handleCompleteOnboarding}
|
|
/>
|
|
|
|
<ManageRemindersModal
|
|
isOpen={isManageRemindersOpen}
|
|
onClose={() => setManageRemindersOpen(false)}
|
|
reminders={customReminders}
|
|
onAdd={() => {
|
|
setManageRemindersOpen(false);
|
|
setAddReminderOpen(true);
|
|
}}
|
|
onEdit={handleOpenEditReminderModal}
|
|
onDelete={handleDeleteReminder}
|
|
/>
|
|
<AddReminderModal
|
|
isOpen={isAddReminderOpen}
|
|
onClose={() => setAddReminderOpen(false)}
|
|
onAdd={handleAddReminder}
|
|
/>
|
|
<EditReminderModal
|
|
isOpen={editingReminder !== null}
|
|
onClose={() => setEditingReminder(null)}
|
|
reminder={editingReminder}
|
|
onUpdate={handleUpdateReminder}
|
|
/>
|
|
|
|
{/* Admin Interface - Only shown when opened */}
|
|
{isAdminInterfaceOpen && (
|
|
<AdminInterface onClose={() => setAdminInterfaceOpen(false)} />
|
|
)}
|
|
|
|
{/* Password Change Modal - Only shown when opened */}
|
|
{isChangePasswordOpen && (
|
|
<ChangePasswordModal
|
|
onClose={() => setChangePasswordOpen(false)}
|
|
onSuccess={() => {
|
|
alert('Password changed successfully!');
|
|
setChangePasswordOpen(false);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const App: React.FC = () => {
|
|
const { user, isLoading } = useUser();
|
|
|
|
// Run database seeding on app startup
|
|
useEffect(() => {
|
|
const runSeeding = async () => {
|
|
try {
|
|
logger.db.query('Initializing database seeding');
|
|
await databaseSeeder.seedDatabase();
|
|
} catch (error) {
|
|
logger.db.error('Database seeding failed', normalizeError(error));
|
|
}
|
|
};
|
|
|
|
runSeeding();
|
|
}, []);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className='min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900'>
|
|
<PillIcon className='w-12 h-12 text-indigo-500 animate-spin' />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!user) {
|
|
if (
|
|
typeof window !== 'undefined' &&
|
|
window.location.pathname === '/reset-password'
|
|
) {
|
|
return <ResetPasswordPage />;
|
|
}
|
|
return <AuthPage />;
|
|
}
|
|
|
|
return <MedicationScheduleApp user={user} />;
|
|
};
|
|
|
|
export default App;
|