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:
William Valentin
2025-09-06 01:42:48 -07:00
commit e48adbcb00
159 changed files with 24405 additions and 0 deletions

View File

@@ -0,0 +1,301 @@
import React, { useState, useEffect, useRef } from 'react';
import { User, UserSettings } from '../../types';
import { CameraIcon, TrashIcon, UserIcon } from '../icons/Icons';
interface AccountModalProps {
isOpen: boolean;
onClose: () => void;
user: User;
settings: UserSettings;
onUpdateUser: (user: User) => Promise<void>;
onUpdateSettings: (settings: UserSettings) => Promise<void>;
onDeleteAllData: () => Promise<void>;
}
const AccountModal: React.FC<AccountModalProps> = ({
isOpen,
onClose,
user,
settings,
onUpdateUser,
onUpdateSettings,
onDeleteAllData,
}) => {
const [username, setUsername] = useState(user.username);
const [successMessage, setSuccessMessage] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
setUsername(user.username);
setSuccessMessage('');
setTimeout(() => closeButtonRef.current?.focus(), 100);
}
}, [isOpen, user.username]);
const handleUsernameSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (username.trim() && username !== user.username) {
setIsSaving(true);
try {
await onUpdateUser({ ...user, username: username.trim() });
setSuccessMessage('Username updated successfully!');
setTimeout(() => setSuccessMessage(''), 3000);
} catch (error) {
alert('Failed to update username.');
} finally {
setIsSaving(false);
}
}
};
const handleToggleNotifications = (
e: React.ChangeEvent<HTMLInputElement>
) => {
onUpdateSettings({ ...settings, notificationsEnabled: e.target.checked });
};
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = async () => {
setIsSaving(true);
try {
await onUpdateUser({ ...user, avatar: reader.result as string });
} finally {
setIsSaving(false);
}
};
reader.readAsDataURL(file);
}
};
const handleRemoveAvatar = async () => {
const { avatar, ...userWithoutAvatar } = user;
setIsSaving(true);
try {
await onUpdateUser(userWithoutAvatar);
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
setIsDeleting(true);
try {
await onDeleteAllData();
} catch (error) {
alert('Failed to delete data.');
} finally {
setIsDeleting(false);
}
};
if (!isOpen) return null;
return (
<div
className='fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 z-50 flex justify-center items-center p-4'
role='dialog'
aria-modal='true'
aria-labelledby='account-title'
>
<div
className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-lg'
ref={modalRef}
>
<div className='p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center'>
<h3
id='account-title'
className='text-xl font-semibold text-slate-800 dark:text-slate-100'
>
Account Settings
</h3>
<button
onClick={onClose}
ref={closeButtonRef}
className='text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-3xl leading-none'
aria-label='Close'
>
&times;
</button>
</div>
<div className='p-6 space-y-6 max-h-[60vh] overflow-y-auto'>
<section>
<h4 className='text-lg font-medium text-slate-700 dark:text-slate-200 mb-3'>
Profile
</h4>
<div className='flex items-center space-x-4'>
<div className='relative'>
{user.avatar ? (
<img
src={user.avatar}
alt='User avatar'
className='w-20 h-20 rounded-full object-cover'
/>
) : (
<span className='w-20 h-20 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center'>
<UserIcon className='w-10 h-10 text-slate-500' />
</span>
)}
<button
onClick={() => fileInputRef.current?.click()}
className='absolute bottom-0 right-0 bg-white dark:bg-slate-600 rounded-full p-1.5 shadow-md border border-slate-200 dark:border-slate-500 hover:bg-slate-100 dark:hover:bg-slate-500'
aria-label='Change profile picture'
>
<CameraIcon className='w-4 h-4 text-slate-700 dark:text-slate-200' />
</button>
<input
type='file'
ref={fileInputRef}
onChange={handleAvatarChange}
accept='image/*'
className='hidden'
/>
</div>
<div className='flex flex-col'>
<button
onClick={() => fileInputRef.current?.click()}
className='px-3 py-1.5 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600'
>
Change Picture
</button>
{user.avatar && (
<button
onClick={handleRemoveAvatar}
className='mt-2 flex items-center text-sm text-red-600 dark:text-red-500 hover:underline'
>
<TrashIcon className='w-3 h-3 mr-1' /> Remove
</button>
)}
</div>
</div>
<form onSubmit={handleUsernameSubmit} className='space-y-3 mt-4'>
<div>
<label
htmlFor='username'
className='block text-sm font-medium text-slate-600 dark:text-slate-300'
>
Username
</label>
<div className='mt-1 flex rounded-md shadow-sm'>
<input
type='text'
id='username'
value={username}
onChange={e => setUsername(e.target.value)}
className='flex-1 block w-full min-w-0 rounded-none rounded-l-md px-3 py-2 border border-slate-300 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'
/>
<button
type='submit'
className='inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-r-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 dark:focus:ring-offset-slate-800'
disabled={
username === user.username || !username.trim() || isSaving
}
>
{isSaving ? 'Saving...' : 'Save'}
</button>
</div>
{successMessage && (
<p className='text-sm text-green-600 dark:text-green-500 mt-2'>
{successMessage}
</p>
)}
</div>
</form>
</section>
<section>
<h4 className='text-lg font-medium text-slate-700 dark:text-slate-200 mb-3'>
Preferences
</h4>
<div className='flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg'>
<span className='font-medium text-slate-800 dark:text-slate-100'>
Enable Notifications
</span>
<label
htmlFor='notifications-toggle'
className='relative inline-flex items-center cursor-pointer'
>
<input
type='checkbox'
id='notifications-toggle'
className='sr-only peer'
checked={settings.notificationsEnabled}
onChange={handleToggleNotifications}
/>
<div className="w-11 h-6 bg-slate-200 dark:bg-slate-600 rounded-full peer peer-focus:ring-4 peer-focus:ring-indigo-300 dark:peer-focus:ring-indigo-800 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-slate-600 peer-checked:bg-indigo-600"></div>
</label>
</div>
</section>
<section>
<h4 className='text-lg font-medium text-red-600 dark:text-red-500 mb-3'>
Danger Zone
</h4>
<div className='p-4 border border-red-300 dark:border-red-500/50 rounded-lg'>
<div className='flex items-center justify-between'>
<div>
<p className='font-semibold text-slate-800 dark:text-slate-100'>
Delete All Data
</p>
<p className='text-sm text-slate-500 dark:text-slate-400'>
Permanently delete all your medications and history.
</p>
</div>
<button
onClick={handleDelete}
disabled={isDeleting}
className='px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 dark:focus:ring-offset-slate-800 disabled:opacity-50 disabled:cursor-not-allowed flex items-center'
>
{isDeleting && <Spinner />}
{isDeleting ? 'Deleting...' : 'Delete Data'}
</button>
</div>
</div>
</section>
</div>
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
<button
type='button'
onClick={onClose}
className='px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600 dark:focus:ring-offset-slate-800'
>
Close
</button>
</div>
</div>
</div>
);
};
const Spinner = () => (
<svg
className='animate-spin -ml-1 mr-3 h-5 w-5 text-white'
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
>
<circle
className='opacity-25'
cx='12'
cy='12'
r='10'
stroke='currentColor'
strokeWidth='4'
></circle>
<path
className='opacity-75'
fill='currentColor'
d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z'
></path>
</svg>
);
export default AccountModal;

View File

@@ -0,0 +1,193 @@
import React, { useState, useEffect, useRef } from 'react';
import { CustomReminder } from '../../types';
import { reminderIcons } from '../icons/Icons';
interface AddReminderModalProps {
isOpen: boolean;
onClose: () => void;
onAdd: (reminder: Omit<CustomReminder, '_id' | '_rev'>) => Promise<void>;
}
const AddReminderModal: React.FC<AddReminderModalProps> = ({
isOpen,
onClose,
onAdd,
}) => {
const [title, setTitle] = useState('');
const [icon, setIcon] = useState('bell');
const [frequencyMinutes, setFrequencyMinutes] = useState(60);
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('17:00');
const [isSaving, setIsSaving] = useState(false);
const titleInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
setTitle('');
setIcon('bell');
setFrequencyMinutes(60);
setStartTime('09:00');
setEndTime('17:00');
setIsSaving(false);
setTimeout(() => titleInputRef.current?.focus(), 100);
}
}, [isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title || isSaving) return;
setIsSaving(true);
try {
await onAdd({
title,
icon,
frequencyMinutes,
startTime,
endTime,
});
} catch (error) {
console.error('Failed to add reminder', error);
alert('There was an error saving your reminder. Please try again.');
} finally {
setIsSaving(false);
}
};
if (!isOpen) return null;
return (
<div
className='fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 z-50 flex justify-center items-center p-4'
role='dialog'
aria-modal='true'
aria-labelledby='add-rem-title'
>
<div className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md'>
<div className='p-6 border-b border-slate-200 dark:border-slate-700'>
<h3
id='add-rem-title'
className='text-xl font-semibold text-slate-800 dark:text-slate-100'
>
Add New Reminder
</h3>
</div>
<form onSubmit={handleSubmit}>
<div className='p-6 space-y-4 max-h-[70vh] overflow-y-auto'>
<div>
<label
htmlFor='rem-title'
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
>
Title
</label>
<input
type='text'
id='rem-title'
value={title}
onChange={e => setTitle(e.target.value)}
required
ref={titleInputRef}
className='mt-1 block w-full px-3 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='e.g., Drink water'
/>
</div>
<div>
<label className='block text-sm font-medium text-slate-700 dark:text-slate-300'>
Icon
</label>
<div className='mt-2 flex flex-wrap gap-2'>
{Object.entries(reminderIcons).map(([key, IconComponent]) => (
<button
key={key}
type='button'
onClick={() => setIcon(key)}
className={`p-2 rounded-full transition-colors ${icon === key ? 'bg-indigo-600 text-white ring-2 ring-offset-2 ring-indigo-500 ring-offset-white dark:ring-offset-slate-800' : 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'}`}
aria-label={`Select ${key} icon`}
>
<IconComponent className='w-6 h-6' />
</button>
))}
</div>
</div>
<div>
<label
htmlFor='rem-frequency'
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
>
Remind me every (minutes)
</label>
<input
type='number'
id='rem-frequency'
value={frequencyMinutes}
onChange={e =>
setFrequencyMinutes(parseInt(e.target.value, 10))
}
min='1'
className='mt-1 block w-full px-3 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'
/>
</div>
<div className='grid grid-cols-2 gap-4'>
<div>
<label
htmlFor='rem-startTime'
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
>
From
</label>
<input
type='time'
id='rem-startTime'
value={startTime}
onChange={e => setStartTime(e.target.value)}
required
className='mt-1 block w-full px-3 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'
/>
</div>
<div>
<label
htmlFor='rem-endTime'
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
>
Until
</label>
<input
type='time'
id='rem-endTime'
value={endTime}
onChange={e => setEndTime(e.target.value)}
required
className='mt-1 block w-full px-3 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'
/>
</div>
</div>
</div>
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end space-x-3 rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
<button
type='button'
onClick={onClose}
disabled={isSaving}
className='px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 disabled:opacity-50 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600'
>
Cancel
</button>
<button
type='submit'
disabled={isSaving}
className='px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center dark:focus:ring-offset-slate-800'
>
{isSaving ? 'Adding...' : 'Add Reminder'}
</button>
</div>
</form>
</div>
</div>
);
};
export default AddReminderModal;

View File

@@ -0,0 +1,195 @@
import React, { useState, useEffect, useRef } from 'react';
import { CustomReminder } from '../../types';
import { reminderIcons } from '../icons/Icons';
interface EditReminderModalProps {
isOpen: boolean;
onClose: () => void;
reminder: CustomReminder | null;
onUpdate: (reminder: CustomReminder) => Promise<void>;
}
const EditReminderModal: React.FC<EditReminderModalProps> = ({
isOpen,
onClose,
reminder,
onUpdate,
}) => {
const [title, setTitle] = useState('');
const [icon, setIcon] = useState('bell');
const [frequencyMinutes, setFrequencyMinutes] = useState(60);
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('17:00');
const [isSaving, setIsSaving] = useState(false);
const titleInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen && reminder) {
setTitle(reminder.title);
setIcon(reminder.icon);
setFrequencyMinutes(reminder.frequencyMinutes);
setStartTime(reminder.startTime);
setEndTime(reminder.endTime);
setIsSaving(false);
setTimeout(() => titleInputRef.current?.focus(), 100);
}
}, [isOpen, reminder]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title || !reminder || isSaving) return;
setIsSaving(true);
try {
await onUpdate({
...reminder,
title,
icon,
frequencyMinutes,
startTime,
endTime,
});
} catch (error) {
console.error('Failed to update reminder', error);
alert('There was an error updating your reminder. Please try again.');
} finally {
setIsSaving(false);
}
};
if (!isOpen) return null;
return (
<div
className='fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 z-50 flex justify-center items-center p-4'
role='dialog'
aria-modal='true'
aria-labelledby='edit-rem-title'
>
<div className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md'>
<div className='p-6 border-b border-slate-200 dark:border-slate-700'>
<h3
id='edit-rem-title'
className='text-xl font-semibold text-slate-800 dark:text-slate-100'
>
Edit Reminder
</h3>
</div>
<form onSubmit={handleSubmit}>
<div className='p-6 space-y-4 max-h-[70vh] overflow-y-auto'>
<div>
<label
htmlFor='rem-edit-title'
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
>
Title
</label>
<input
type='text'
id='rem-edit-title'
value={title}
onChange={e => setTitle(e.target.value)}
required
ref={titleInputRef}
className='mt-1 block w-full px-3 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'
/>
</div>
<div>
<label className='block text-sm font-medium text-slate-700 dark:text-slate-300'>
Icon
</label>
<div className='mt-2 flex flex-wrap gap-2'>
{Object.entries(reminderIcons).map(([key, IconComponent]) => (
<button
key={key}
type='button'
onClick={() => setIcon(key)}
className={`p-2 rounded-full transition-colors ${icon === key ? 'bg-indigo-600 text-white ring-2 ring-offset-2 ring-indigo-500 ring-offset-white dark:ring-offset-slate-800' : 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'}`}
aria-label={`Select ${key} icon`}
>
<IconComponent className='w-6 h-6' />
</button>
))}
</div>
</div>
<div>
<label
htmlFor='rem-edit-frequency'
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
>
Remind me every (minutes)
</label>
<input
type='number'
id='rem-edit-frequency'
value={frequencyMinutes}
onChange={e =>
setFrequencyMinutes(parseInt(e.target.value, 10))
}
min='1'
className='mt-1 block w-full px-3 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'
/>
</div>
<div className='grid grid-cols-2 gap-4'>
<div>
<label
htmlFor='rem-edit-startTime'
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
>
From
</label>
<input
type='time'
id='rem-edit-startTime'
value={startTime}
onChange={e => setStartTime(e.target.value)}
required
className='mt-1 block w-full px-3 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'
/>
</div>
<div>
<label
htmlFor='rem-edit-endTime'
className='block text-sm font-medium text-slate-700 dark:text-slate-300'
>
Until
</label>
<input
type='time'
id='rem-edit-endTime'
value={endTime}
onChange={e => setEndTime(e.target.value)}
required
className='mt-1 block w-full px-3 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'
/>
</div>
</div>
</div>
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end space-x-3 rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
<button
type='button'
onClick={onClose}
disabled={isSaving}
className='px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 disabled:opacity-50 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600'
>
Cancel
</button>
<button
type='submit'
disabled={isSaving}
className='px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center dark:focus:ring-offset-slate-800'
>
{isSaving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
);
};
export default EditReminderModal;

View File

@@ -0,0 +1,206 @@
import React, { useEffect, useRef } from 'react';
import { HistoricalDose } from '../../types';
import {
PillIcon,
CheckCircleIcon,
XCircleIcon,
ClockIcon,
} from '../icons/Icons';
interface HistoryModalProps {
isOpen: boolean;
onClose: () => void;
history: { date: string; doses: HistoricalDose[] }[];
}
const getStatusIcon = (status: HistoricalDose['status']) => {
switch (status) {
case 'TAKEN':
return (
<CheckCircleIcon className='w-5 h-5 text-green-500 dark:text-green-400' />
);
case 'MISSED':
return <XCircleIcon className='w-5 h-5 text-red-500 dark:text-red-400' />;
default:
return (
<ClockIcon className='w-5 h-5 text-slate-400 dark:text-slate-500' />
);
}
};
const HistoryModal: React.FC<HistoryModalProps> = ({
isOpen,
onClose,
history,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) {
setTimeout(() => closeButtonRef.current?.focus(), 100);
}
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
useEffect(() => {
if (!isOpen || !modalRef.current) return;
const focusableElements = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[
focusableElements.length - 1
] as HTMLElement;
const handleTabKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
}
};
document.addEventListener('keydown', handleTabKey);
return () => document.removeEventListener('keydown', handleTabKey);
}, [isOpen]);
if (!isOpen) return null;
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const userTimezoneOffset = date.getTimezoneOffset() * 60000;
return new Date(date.getTime() + userTimezoneOffset).toLocaleDateString(
undefined,
{
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}
);
};
return (
<div
className='fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 z-50 flex justify-center items-center p-4'
role='dialog'
aria-modal='true'
aria-labelledby='history-title'
>
<div
className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-2xl'
ref={modalRef}
>
<div className='p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center'>
<h3
id='history-title'
className='text-xl font-semibold text-slate-800 dark:text-slate-100'
>
Medication History
</h3>
<button
onClick={onClose}
ref={closeButtonRef}
className='text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-3xl leading-none'
aria-label='Close'
>
&times;
</button>
</div>
<div className='p-6 max-h-[60vh] overflow-y-auto space-y-6'>
{history.length > 0 ? (
history.map(({ date, doses }) => (
<section key={date}>
<h4 className='font-bold text-slate-700 dark:text-slate-300 mb-3'>
{formatDate(date)}
</h4>
<ul className='space-y-2'>
{doses.map(dose => (
<li
key={dose.id}
className='p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg flex justify-between items-center'
>
<div className='flex items-center space-x-3'>
<div className='flex-shrink-0'>
{getStatusIcon(dose.status)}
</div>
<div>
<p className='font-semibold text-slate-800 dark:text-slate-100'>
{dose.medication.name}
</p>
<p className='text-sm text-slate-500 dark:text-slate-400'>
{dose.medication.dosage}
</p>
</div>
</div>
<div className='text-right'>
<p className='font-medium text-slate-700 dark:text-slate-300'>
{dose.scheduledTime.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</p>
{dose.status === 'TAKEN' && dose.takenAt && (
<p className='text-xs text-green-600 dark:text-green-500'>
Taken at{' '}
{new Date(dose.takenAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</p>
)}
{dose.status === 'MISSED' && (
<p className='text-xs text-red-600 dark:text-red-500'>
Missed
</p>
)}
</div>
</li>
))}
</ul>
</section>
))
) : (
<div className='text-center py-10'>
<PillIcon className='w-12 h-12 mx-auto text-slate-300 dark:text-slate-600' />
<p className='mt-4 text-slate-500 dark:text-slate-400'>
No medication history found.
</p>
<p className='text-sm text-slate-400 dark:text-slate-500'>
History will appear here once you start tracking doses.
</p>
</div>
)}
</div>
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
<button
type='button'
onClick={onClose}
className='px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600 dark:focus:ring-offset-slate-800'
>
Close
</button>
</div>
</div>
</div>
);
};
export default HistoryModal;

View File

@@ -0,0 +1,127 @@
import React from 'react';
import { CustomReminder } from '../../types';
import { TrashIcon, EditIcon, PlusIcon, getReminderIcon } from '../icons/Icons';
interface ManageRemindersModalProps {
isOpen: boolean;
onClose: () => void;
reminders: CustomReminder[];
onAdd: () => void;
onDelete: (reminder: CustomReminder) => void;
onEdit: (reminder: CustomReminder) => void;
}
const ManageRemindersModal: React.FC<ManageRemindersModalProps> = ({
isOpen,
onClose,
reminders,
onAdd,
onDelete,
onEdit,
}) => {
const handleDeleteConfirmation = (reminder: CustomReminder) => {
if (
window.confirm(
`Are you sure you want to delete the reminder "${reminder.title}"?`
)
) {
onDelete(reminder);
}
};
if (!isOpen) return null;
return (
<div
className='fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 z-50 flex justify-center items-center p-4'
role='dialog'
aria-modal='true'
aria-labelledby='manage-rem-title'
>
<div className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-lg'>
<div className='p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center'>
<h3
id='manage-rem-title'
className='text-xl font-semibold text-slate-800 dark:text-slate-100'
>
Manage Custom Reminders
</h3>
<button
onClick={onClose}
className='text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-3xl leading-none'
aria-label='Close'
>
&times;
</button>
</div>
<div className='p-6 max-h-[60vh] overflow-y-auto'>
{reminders.length > 0 ? (
<ul className='space-y-3'>
{reminders.map(rem => {
const ReminderIcon = getReminderIcon(rem.icon);
return (
<li
key={rem._id}
className='p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg flex justify-between items-center'
>
<div className='flex items-center space-x-3'>
<ReminderIcon className='w-6 h-6 text-sky-500 dark:text-sky-400 flex-shrink-0' />
<div>
<p className='font-semibold text-slate-800 dark:text-slate-100'>
{rem.title}
</p>
<p className='text-sm text-slate-500 dark:text-slate-400'>
Every {rem.frequencyMinutes} mins from {rem.startTime}{' '}
to {rem.endTime}
</p>
</div>
</div>
<div className='flex items-center space-x-1'>
<button
onClick={() => onEdit(rem)}
className='p-2 text-indigo-600 hover:bg-indigo-100 dark:text-indigo-400 dark:hover:bg-slate-700 rounded-full'
aria-label={`Edit ${rem.title}`}
>
<EditIcon className='w-5 h-5' />
</button>
<button
onClick={() => handleDeleteConfirmation(rem)}
className='p-2 text-red-500 hover:bg-red-100 dark:text-red-400 dark:hover:bg-slate-700 rounded-full'
aria-label={`Delete ${rem.title}`}
>
<TrashIcon className='w-5 h-5' />
</button>
</div>
</li>
);
})}
</ul>
) : (
<p className='text-center text-slate-500 dark:text-slate-400 py-8'>
No custom reminders have been added yet.
</p>
)}
</div>
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-between items-center rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
<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 New Reminder
</button>
<button
type='button'
onClick={onClose}
className='px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600 dark:focus:ring-offset-slate-800'
>
Close
</button>
</div>
</div>
</div>
);
};
export default ManageRemindersModal;

View File

@@ -0,0 +1,81 @@
import React, { useState } from 'react';
import { PillIcon, PlusIcon, CheckCircleIcon } from './icons/Icons';
interface OnboardingModalProps {
isOpen: boolean;
onComplete: () => void;
}
const onboardingSteps = [
{
icon: PillIcon,
title: 'Welcome to Medication Reminder!',
description:
'This quick tour will show you how to get the most out of the app.',
},
{
icon: PlusIcon,
title: 'Add Your Medications',
description:
"Start by clicking the 'Add Medication' button. You can set the name, dosage, frequency, and a custom icon.",
},
{
icon: CheckCircleIcon,
title: 'Track Your Doses',
description:
"Your daily schedule will appear on the main screen. Simply tap 'Take' to record a dose and stay on track with your health.",
},
];
const OnboardingModal: React.FC<OnboardingModalProps> = ({
isOpen,
onComplete,
}) => {
const [step, setStep] = useState(0);
const currentStep = onboardingSteps[step];
const isLastStep = step === onboardingSteps.length - 1;
const handleNext = () => {
if (isLastStep) {
onComplete();
} else {
setStep(s => s + 1);
}
};
if (!isOpen) return null;
return (
<div className='fixed inset-0 bg-black bg-opacity-60 dark:bg-opacity-80 z-50 flex justify-center items-center p-4'>
<div className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-sm text-center p-8 m-4'>
<div className='mx-auto bg-indigo-100 dark:bg-indigo-900/50 rounded-full h-16 w-16 flex items-center justify-center mb-6'>
<currentStep.icon className='w-8 h-8 text-indigo-600 dark:text-indigo-400' />
</div>
<h2 className='text-2xl font-bold text-slate-800 dark:text-slate-100 mb-2'>
{currentStep.title}
</h2>
<p className='text-slate-600 dark:text-slate-300 mb-8'>
{currentStep.description}
</p>
<div className='flex justify-center items-center mb-8 space-x-2'>
{onboardingSteps.map((_, index) => (
<div
key={index}
className={`w-2.5 h-2.5 rounded-full transition-colors ${step === index ? 'bg-indigo-600' : 'bg-slate-300 dark:bg-slate-600'}`}
/>
))}
</div>
<button
onClick={handleNext}
className='w-full px-4 py-3 text-lg font-semibold 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-800'
>
{isLastStep ? 'Get Started' : 'Next'}
</button>
</div>
</div>
);
};
export default OnboardingModal;

View File

@@ -0,0 +1,244 @@
import React, { useEffect, useRef } from 'react';
import { DailyStat, MedicationStat } from '../../types';
import BarChart from '../ui/BarChart';
import { BarChartIcon, getMedicationIcon } from '../icons/Icons';
interface StatsModalProps {
isOpen: boolean;
onClose: () => void;
dailyStats: DailyStat[];
medicationStats: MedicationStat[];
}
const formatLastTaken = (isoString?: string) => {
if (!isoString)
return <span className='text-slate-400 dark:text-slate-500'>N/A</span>;
const date = new Date(isoString);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() - 1
);
const timeString = date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
const datePart = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate()
);
if (datePart.getTime() === today.getTime()) {
return `Today at ${timeString}`;
}
if (datePart.getTime() === yesterday.getTime()) {
return `Yesterday at ${timeString}`;
}
return date.toLocaleDateString();
};
const StatsModal: React.FC<StatsModalProps> = ({
isOpen,
onClose,
dailyStats,
medicationStats,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) {
setTimeout(() => closeButtonRef.current?.focus(), 100);
}
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
if (isOpen) {
window.addEventListener('keydown', handleKeyDown);
}
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
const hasData = medicationStats.length > 0;
return (
<div
className='fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 z-50 flex justify-center items-center p-4'
role='dialog'
aria-modal='true'
aria-labelledby='stats-title'
>
<div
className='bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-3xl'
ref={modalRef}
>
<div className='p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center'>
<h3
id='stats-title'
className='text-xl font-semibold text-slate-800 dark:text-slate-100'
>
Medication Statistics
</h3>
<button
onClick={onClose}
ref={closeButtonRef}
className='text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-3xl leading-none'
aria-label='Close'
>
&times;
</button>
</div>
<div className='p-6 max-h-[70vh] overflow-y-auto space-y-8'>
{hasData ? (
<>
<section>
<h4 className='text-lg font-semibold text-slate-700 dark:text-slate-200 mb-4'>
Weekly Adherence
</h4>
<div className='p-4 bg-slate-50 dark:bg-slate-700/50 rounded-lg'>
<BarChart data={dailyStats} />
</div>
</section>
<section>
<h4 className='text-lg font-semibold text-slate-700 dark:text-slate-200 mb-4'>
Medication Breakdown
</h4>
<div className='overflow-x-auto'>
<table className='min-w-full divide-y divide-slate-200 dark:divide-slate-700'>
<thead className='bg-slate-50 dark:bg-slate-700/50'>
<tr>
<th
scope='col'
className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider'
>
Medication
</th>
<th
scope='col'
className='px-4 py-3 text-center text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider'
>
Taken
</th>
<th
scope='col'
className='px-4 py-3 text-center text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider'
>
Missed
</th>
<th
scope='col'
className='px-4 py-3 text-center text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider'
>
Upcoming
</th>
<th
scope='col'
className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider'
>
Last Taken
</th>
<th
scope='col'
className='px-4 py-3 text-right text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider'
>
Adherence
</th>
</tr>
</thead>
<tbody className='bg-white dark:bg-slate-800 divide-y divide-slate-200 dark:divide-slate-700'>
{medicationStats.map(
({
medication,
taken,
missed,
upcoming,
adherence,
lastTakenAt,
}) => {
const MedicationIcon = getMedicationIcon(
medication.icon
);
const adherenceColor =
adherence >= 90
? 'text-green-600 dark:text-green-400'
: adherence >= 70
? 'text-amber-600 dark:text-amber-400'
: 'text-red-600 dark:text-red-400';
return (
<tr key={medication._id}>
<td className='px-4 py-4 whitespace-nowrap'>
<div className='flex items-center space-x-3'>
<MedicationIcon className='w-6 h-6 text-indigo-500 dark:text-indigo-400 flex-shrink-0' />
<div>
<div className='text-sm font-semibold text-slate-900 dark:text-slate-100'>
{medication.name}
</div>
<div className='text-xs text-slate-500 dark:text-slate-400'>
{medication.dosage}
</div>
</div>
</div>
</td>
<td className='px-4 py-4 whitespace-nowrap text-center text-sm text-slate-500 dark:text-slate-400'>
{taken}
</td>
<td className='px-4 py-4 whitespace-nowrap text-center text-sm text-slate-500 dark:text-slate-400'>
{missed}
</td>
<td className='px-4 py-4 whitespace-nowrap text-center text-sm text-slate-500 dark:text-slate-400'>
{upcoming}
</td>
<td className='px-4 py-4 whitespace-nowrap text-sm text-slate-500 dark:text-slate-400'>
{formatLastTaken(lastTakenAt)}
</td>
<td
className={`px-4 py-4 whitespace-nowrap text-right text-sm font-bold ${adherenceColor}`}
>
{adherence}%
</td>
</tr>
);
}
)}
</tbody>
</table>
</div>
</section>
</>
) : (
<div className='text-center py-10'>
<BarChartIcon className='w-12 h-12 mx-auto text-slate-300 dark:text-slate-600' />
<p className='mt-4 text-slate-500 dark:text-slate-400'>
Not enough data to display stats.
</p>
<p className='text-sm text-slate-400 dark:text-slate-500'>
Statistics will appear here once you start tracking your doses.
</p>
</div>
)}
</div>
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
<button
type='button'
onClick={onClose}
className='px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-md shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:bg-slate-700 dark:text-slate-200 dark:border-slate-600 dark:hover:bg-slate-600 dark:focus:ring-offset-slate-800'
>
Close
</button>
</div>
</div>
</div>
);
};
export default StatsModal;

View File

@@ -0,0 +1,8 @@
// Modal Components
export { default as AccountModal } from './AccountModal';
export { default as AddReminderModal } from './AddReminderModal';
export { default as EditReminderModal } from './EditReminderModal';
export { default as HistoryModal } from './HistoryModal';
export { default as ManageRemindersModal } from './ManageRemindersModal';
export { default as OnboardingModal } from './OnboardingModal';
export { default as StatsModal } from './StatsModal';