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:
301
components/modals/AccountModal.tsx
Normal file
301
components/modals/AccountModal.tsx
Normal 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'
|
||||
>
|
||||
×
|
||||
</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;
|
||||
193
components/modals/AddReminderModal.tsx
Normal file
193
components/modals/AddReminderModal.tsx
Normal 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;
|
||||
195
components/modals/EditReminderModal.tsx
Normal file
195
components/modals/EditReminderModal.tsx
Normal 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;
|
||||
206
components/modals/HistoryModal.tsx
Normal file
206
components/modals/HistoryModal.tsx
Normal 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'
|
||||
>
|
||||
×
|
||||
</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;
|
||||
127
components/modals/ManageRemindersModal.tsx
Normal file
127
components/modals/ManageRemindersModal.tsx
Normal 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'
|
||||
>
|
||||
×
|
||||
</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;
|
||||
81
components/modals/OnboardingModal.tsx
Normal file
81
components/modals/OnboardingModal.tsx
Normal 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;
|
||||
244
components/modals/StatsModal.tsx
Normal file
244
components/modals/StatsModal.tsx
Normal 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'
|
||||
>
|
||||
×
|
||||
</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;
|
||||
8
components/modals/index.ts
Normal file
8
components/modals/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user