Initial commit: Complete NodeJS-native setup
- Migrated from Python pre-commit to NodeJS-native solution - Reorganized documentation structure - Set up Husky + lint-staged for efficient pre-commit hooks - Fixed Dockerfile healthcheck issue - Added comprehensive documentation index
This commit is contained in:
939
App.tsx
Normal file
939
App.tsx
Normal file
@@ -0,0 +1,939 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { generateSchedule, generateReminderSchedule } from './utils/schedule';
|
||||
import {
|
||||
Medication,
|
||||
Dose,
|
||||
DoseStatus,
|
||||
HistoricalDose,
|
||||
User,
|
||||
UserSettings,
|
||||
TakenDoses,
|
||||
CustomReminder,
|
||||
ScheduleItem,
|
||||
DailyStat,
|
||||
MedicationStat,
|
||||
UserRole,
|
||||
} from './types';
|
||||
|
||||
// Component imports - organized by feature
|
||||
import {
|
||||
AddMedicationModal,
|
||||
EditMedicationModal,
|
||||
ManageMedicationsModal,
|
||||
DoseCard,
|
||||
} from './components/medication';
|
||||
import {
|
||||
AuthPage,
|
||||
AvatarDropdown,
|
||||
ChangePasswordModal,
|
||||
} from './components/auth';
|
||||
import { AdminInterface } from './components/admin';
|
||||
import {
|
||||
AccountModal,
|
||||
AddReminderModal,
|
||||
EditReminderModal,
|
||||
HistoryModal,
|
||||
ManageRemindersModal,
|
||||
OnboardingModal,
|
||||
StatsModal,
|
||||
} from './components/modals';
|
||||
import { BarChart, 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 { dbService } from './services/couchdb.factory';
|
||||
import { databaseSeeder } from './services/database.seeder';
|
||||
|
||||
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>Meds</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) {
|
||||
console.warn('Skipping data fetch: user._id is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log('Fetching data for user:', user._id);
|
||||
|
||||
const [medsData, remindersData, takenDosesData, settingsData] =
|
||||
await Promise.all([
|
||||
dbService.getMedications(user._id),
|
||||
dbService.getCustomReminders(user._id),
|
||||
dbService.getTakenDoses(user._id),
|
||||
dbService.getSettings(user._id),
|
||||
]);
|
||||
|
||||
console.log('Data fetched successfully:', {
|
||||
medications: medsData.length,
|
||||
reminders: 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.');
|
||||
console.error('Error loading user data:', e);
|
||||
console.error('User object:', user);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Add a small delay to ensure user state is fully settled
|
||||
const timeoutId = setTimeout(() => {
|
||||
fetchData();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [user._id]);
|
||||
|
||||
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 dbService.addMedication(user._id, med);
|
||||
setMedications(prev => [...prev, newMed]);
|
||||
setAddModalOpen(false);
|
||||
};
|
||||
|
||||
const handleDeleteMedication = async (medToDelete: Medication) => {
|
||||
await dbService.deleteMedication(user._id, medToDelete);
|
||||
setMedications(meds => meds.filter(med => med._id !== medToDelete._id));
|
||||
};
|
||||
|
||||
const handleUpdateMedication = async (updatedMed: Medication) => {
|
||||
const savedMed = await dbService.updateMedication(user._id, 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 dbService.addCustomReminder(user._id, reminder);
|
||||
setCustomReminders(prev => [...prev, newReminder]);
|
||||
setAddReminderOpen(false);
|
||||
};
|
||||
|
||||
const handleUpdateReminder = async (updatedReminder: CustomReminder) => {
|
||||
const savedReminder = await dbService.updateCustomReminder(
|
||||
user._id,
|
||||
updatedReminder
|
||||
);
|
||||
setCustomReminders(reminders =>
|
||||
reminders.map(r => (r._id === savedReminder._id ? savedReminder : r))
|
||||
);
|
||||
setEditingReminder(null);
|
||||
};
|
||||
|
||||
const handleDeleteReminder = async (reminderToDelete: CustomReminder) => {
|
||||
await dbService.deleteCustomReminder(user._id, reminderToDelete);
|
||||
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 };
|
||||
if (newDoses[doseId]) {
|
||||
delete newDoses[doseId];
|
||||
} else {
|
||||
newDoses[doseId] = new Date().toISOString();
|
||||
}
|
||||
const updatedDoc = await dbService.updateTakenDoses(user._id, {
|
||||
...takenDosesDoc,
|
||||
doses: newDoses,
|
||||
});
|
||||
setTakenDosesDoc(updatedDoc);
|
||||
},
|
||||
[takenDosesDoc, user._id]
|
||||
);
|
||||
|
||||
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 => {
|
||||
if (takenDoses[dose.id]) return DoseStatus.TAKEN;
|
||||
if (snoozedDoses[dose.id] && new Date(snoozedDoses[dose.id]) > now)
|
||||
return DoseStatus.SNOOZED;
|
||||
if (doseTime.getTime() < now.getTime()) return DoseStatus.MISSED;
|
||||
return DoseStatus.UPCOMING;
|
||||
},
|
||||
[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;
|
||||
|
||||
return {
|
||||
...item,
|
||||
type: 'dose' as const,
|
||||
medication,
|
||||
status: getDoseStatus(item, item.scheduledTime, currentTime),
|
||||
takenAt: takenDoses[item.id],
|
||||
snoozedUntil: snoozedDoses[item.id]
|
||||
? new Date(snoozedDoses[item.id])
|
||||
: undefined,
|
||||
};
|
||||
} 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 = '';
|
||||
|
||||
if (item.type === 'dose' && item.status === DoseStatus.UPCOMING) {
|
||||
timeToNotification = item.scheduledTime.getTime() - now.getTime();
|
||||
notificationTitle = 'Time for your medication!';
|
||||
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
|
||||
} else if (
|
||||
item.type === 'dose' &&
|
||||
item.status === DoseStatus.SNOOZED &&
|
||||
item.snoozedUntil
|
||||
) {
|
||||
timeToNotification = item.snoozedUntil.getTime() - now.getTime();
|
||||
notificationTitle = 'Snoozed Medication Reminder';
|
||||
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
|
||||
} else if (item.type === 'reminder' && item.scheduledTime > now) {
|
||||
timeToNotification = item.scheduledTime.getTime() - now.getTime();
|
||||
notificationTitle = 'Reminder';
|
||||
notificationBody = item.title;
|
||||
}
|
||||
|
||||
if (timeToNotification > 0) {
|
||||
activeTimers[itemId] = setTimeout(() => {
|
||||
new Notification(notificationTitle, {
|
||||
body: notificationBody,
|
||||
tag: itemId,
|
||||
});
|
||||
if (item.type === 'dose' && item.status === DoseStatus.SNOOZED) {
|
||||
setSnoozedDoses(prev => {
|
||||
const newSnoozed = { ...prev };
|
||||
delete newSnoozed[itemId];
|
||||
return newSnoozed;
|
||||
});
|
||||
}
|
||||
delete activeTimers[itemId];
|
||||
}, timeToNotification) as unknown as number;
|
||||
}
|
||||
});
|
||||
|
||||
return () => Object.values(activeTimers).forEach(clearTimeout);
|
||||
}, [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 dbService.updateSettings(
|
||||
user._id,
|
||||
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 dbService.deleteAllUserData(user._id);
|
||||
setMedications([]);
|
||||
setCustomReminders([]);
|
||||
const updatedTakenDoses = await dbService.getTakenDoses(user._id);
|
||||
setTakenDosesDoc(updatedTakenDoses);
|
||||
setAccountModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteOnboarding = async () => {
|
||||
if (settings) {
|
||||
try {
|
||||
const updatedSettings = await dbService.updateSettings(user._id, {
|
||||
...settings,
|
||||
hasCompletedOnboarding: true,
|
||||
});
|
||||
setSettings(updatedSettings);
|
||||
setOnboardingOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to update onboarding status', error);
|
||||
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 {
|
||||
console.log('🌱 Initializing database seeding...');
|
||||
await databaseSeeder.seedDatabase();
|
||||
} catch (error) {
|
||||
console.error('❌ Database seeding failed:', 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) {
|
||||
return <AuthPage />;
|
||||
}
|
||||
|
||||
return <MedicationScheduleApp user={user} />;
|
||||
};
|
||||
|
||||
export default App;
|
||||
Reference in New Issue
Block a user