Patient Portal Architecture¶
The patient portal is a mobile-first React interface for participants and caregivers.
Overview¶
- Framework: React 18
- State Management: React Query (TanStack Query)
- Routing: React Router v6
- Styling: Tailwind CSS 4
- Mobile: Capacitor 6.x for iOS/Android
- i18n: i18next (English/French)
- Build Tool: Vite
- Port: http://localhost:3001
Project Structure¶
patient-portal/
├── src/
│ ├── main.tsx # Entry point with React Query provider
│ ├── App.tsx # Root component with routing
│ ├── pages/
│ │ ├── Home.tsx # Dashboard with upcoming tasks
│ │ ├── Tasks.tsx # Task list
│ │ ├── TaskDetail.tsx # Individual task details
│ │ ├── Assessments/
│ │ │ ├── AssessmentLanding.tsx
│ │ │ └── AssessmentComplete.tsx
│ │ ├── Consent/
│ │ │ ├── ConsentReview.tsx
│ │ │ └── ConsentSign.tsx
│ │ ├── Schedule.tsx # Visit schedule
│ │ ├── Settings.tsx # Profile and preferences
│ │ ├── Contact.tsx # Contact study team
│ │ └── Summary.tsx # Progress summary
│ ├── components/
│ │ ├── AppShell.tsx # Main layout with bottom nav
│ │ ├── BottomNav.tsx # Mobile navigation
│ │ ├── ActorBadge.tsx # Patient/Caregiver indicator
│ │ ├── StepProgress.tsx # Multi-step progress bar
│ │ └── AssessmentExitModal.tsx
│ ├── contexts/
│ │ └── AuthContext.tsx # Magic link authentication
│ ├── services/
│ │ └── api.ts # API client
│ ├── i18n/ # Translations (en, fr)
│ └── styles/
├── capacitor.config.ts # Capacitor mobile configuration
├── tailwind.config.ts
└── vite.config.ts
Authentication¶
Magic Link Flow¶
Passwordless authentication using email magic links:
// pages/Login.tsx
function Login() {
const [email, setEmail] = useState('');
const [sent, setSent] = useState(false);
const handleSendMagicLink = async () => {
await api.sendMagicLink(email);
setSent(true);
};
return (
<div>
{!sent ? (
<>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
/>
<button onClick={handleSendMagicLink}>
Send Magic Link
</button>
</>
) : (
<p>Check your email for a login link</p>
)}
</div>
);
}
Magic Link Handler¶
// pages/Auth.tsx
function MagicLinkAuth() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
useEffect(() => {
const token = searchParams.get('token');
if (token) {
localStorage.setItem('auth_token', token);
// Verify token and fetch user
api.getCurrentUser().then(user => {
navigate('/');
}).catch(() => {
navigate('/login');
});
}
}, [searchParams, navigate]);
return <LoadingScreen />;
}
Mobile-First UI¶
Bottom Navigation¶
// components/BottomNav.tsx
function BottomNav() {
const location = useLocation();
const tabs = [
{ path: '/', icon: 'home', label: 'Home' },
{ path: '/tasks', icon: 'clipboard-list', label: 'Tasks' },
{ path: '/schedule', icon: 'calendar', label: 'Schedule' },
{ path: '/settings', icon: 'settings', label: 'Settings' },
];
return (
<nav className="bottom-nav">
{tabs.map(tab => (
<Link
key={tab.path}
to={tab.path}
className={location.pathname === tab.path ? 'active' : ''}
>
<Icon name={tab.icon} />
<span>{tab.label}</span>
</Link>
))}
</nav>
);
}
Responsive Layout¶
// components/AppShell.tsx
function AppShell({ children }: { children: React.ReactNode }) {
return (
<div className="app-shell">
<header className="app-header">
<h1>Metricis</h1>
</header>
<main className="app-content">
{children}
</main>
<BottomNav />
</div>
);
}
Styling:
.app-shell {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh; /* Dynamic viewport height for mobile */
}
.app-content {
flex: 1;
overflow-y: auto;
padding-bottom: calc(env(safe-area-inset-bottom) + 60px);
}
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding-bottom: env(safe-area-inset-bottom);
background: white;
border-top: 1px solid #e2e8f0;
}
Task Management¶
Task List¶
// pages/Tasks.tsx
function Tasks() {
const { data: tasks, isLoading } = useQuery({
queryKey: ['tasks'],
queryFn: () => api.getTasks(),
});
if (isLoading) return <LoadingSpinner />;
const upcomingTasks = tasks.filter(t => !t.completed);
const completedTasks = tasks.filter(t => t.completed);
return (
<div className="tasks-page">
<h1>My Tasks</h1>
<section>
<h2>Upcoming</h2>
{upcomingTasks.map(task => (
<TaskCard key={task.id} task={task} />
))}
</section>
<section>
<h2>Completed</h2>
{completedTasks.map(task => (
<TaskCard key={task.id} task={task} />
))}
</section>
</div>
);
}
Task Card¶
interface TaskCardProps {
task: Task;
}
function TaskCard({ task }: TaskCardProps) {
return (
<Link to={`/tasks/${task.id}`} className="task-card">
<div className="task-icon">
{task.type === 'assessment' ? '📝' : '✓'}
</div>
<div className="task-content">
<h3>{task.title}</h3>
<p className="task-description">{task.description}</p>
<div className="task-meta">
<span className="task-due">Due: {formatDate(task.dueDate)}</span>
{task.completed && (
<span className="task-completed">✓ Completed</span>
)}
</div>
</div>
<ActorBadge actor={task.assignedTo} />
</Link>
);
}
eConsent¶
Consent Review¶
// pages/Consent/ConsentReview.tsx
function ConsentReview() {
const { consentId } = useParams();
const { data: consent } = useQuery({
queryKey: ['consent', consentId],
queryFn: () => api.getConsent(consentId),
});
return (
<div className="consent-review">
<div className="consent-document">
<h1>{consent.title}</h1>
<div dangerouslySetInnerHTML={{ __html: consent.content }} />
</div>
<StepProgress current={1} total={2} />
<Link to={`/consent/${consentId}/sign`} className="btn-primary">
Continue to Sign
</Link>
</div>
);
}
Signature Capture¶
// pages/Consent/ConsentSign.tsx
import SignatureCanvas from 'react-signature-canvas';
function ConsentSign() {
const sigRef = useRef<SignatureCanvas>(null);
const signConsent = useSignConsent();
const handleSign = () => {
const signature = sigRef.current?.toDataURL();
signConsent.mutate({ consentId, signature });
};
return (
<div className="consent-sign">
<h2>Sign Consent</h2>
<div className="signature-pad">
<SignatureCanvas
ref={sigRef}
canvasProps={{ className: 'signature-canvas' }}
/>
<button onClick={() => sigRef.current?.clear()}>Clear</button>
</div>
<StepProgress current={2} total={2} />
<button onClick={handleSign} className="btn-primary">
Submit Signature
</button>
</div>
);
}
Assessment Integration¶
Assessment Landing¶
// pages/Assessments/AssessmentLanding.tsx
function AssessmentLanding() {
const { assessmentId } = useParams();
const { data: assessment } = useQuery({
queryKey: ['assessment', assessmentId],
queryFn: () => api.getAssessment(assessmentId),
});
const startAssessment = () => {
// Launch Capacitor-embedded client assessment
window.location.href = `metricis://assess?session=${assessment.sessionId}`;
};
return (
<div className="assessment-landing">
<h1>{assessment.title}</h1>
<p>{assessment.description}</p>
<div className="assessment-info">
<div>Estimated time: {assessment.estimatedMinutes} minutes</div>
<div>Tasks: {assessment.taskCount}</div>
</div>
<button onClick={startAssessment} className="btn-primary">
Start Assessment
</button>
</div>
);
}
Internationalization¶
Language Selector¶
// pages/Settings.tsx
import { useTranslation } from 'react-i18next';
function Settings() {
const { i18n } = useTranslation();
const handleLanguageChange = (lang: string) => {
i18n.changeLanguage(lang);
localStorage.setItem('language', lang);
};
return (
<div className="settings">
<h1>Settings</h1>
<section>
<h2>Language</h2>
<select
value={i18n.language}
onChange={(e) => handleLanguageChange(e.target.value)}
>
<option value="en">English</option>
<option value="fr">Français</option>
</select>
</section>
</div>
);
}
Translation Usage¶
// pages/Home.tsx
import { useTranslation } from 'react-i18next';
function Home() {
const { t } = useTranslation();
return (
<div>
<h1>{t('home.welcome')}</h1>
<p>{t('home.upcomingTasks', { count: taskCount })}</p>
</div>
);
}
Translation files:
// i18n/en.json
{
"home": {
"welcome": "Welcome to Metricis",
"upcomingTasks": "You have {{count}} upcoming task"
},
"tasks": {
"title": "My Tasks",
"due": "Due: {{date}}",
"completed": "Completed"
}
}
Offline Support¶
Using Capacitor Storage API:
import { Storage } from '@capacitor/storage';
export async function cacheTask(task: Task) {
await Storage.set({
key: `task_${task.id}`,
value: JSON.stringify(task),
});
}
export async function getCachedTask(taskId: string): Promise<Task | null> {
const { value } = await Storage.get({ key: `task_${taskId}` });
return value ? JSON.parse(value) : null;
}
Push Notifications¶
import { PushNotifications } from '@capacitor/push-notifications';
// Request permission and register
export async function registerPushNotifications() {
const result = await PushNotifications.requestPermissions();
if (result.receive === 'granted') {
await PushNotifications.register();
}
// Listen for registration
PushNotifications.addListener('registration', (token) => {
// Send token to server
api.registerPushToken(token.value);
});
// Listen for push notifications
PushNotifications.addListener('pushNotificationReceived', (notification) => {
// Handle notification
});
}
Actor Support (Patient/Caregiver)¶
// components/ActorBadge.tsx
interface ActorBadgeProps {
actor: 'patient' | 'caregiver';
}
function ActorBadge({ actor }: ActorBadgeProps) {
return (
<span className={`actor-badge actor-${actor}`}>
{actor === 'patient' ? '👤 Patient' : '👥 Caregiver'}
</span>
);
}
Styling:
.actor-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.actor-patient {
background: #dbeafe;
color: #1e40af;
}
.actor-caregiver {
background: #fce7f3;
color: #9f1239;
}
Next Steps¶
- Architecture Overview - System design
- Client Architecture - Assessment interface
- Portal Architecture - Researcher portal
- Server Architecture - Backend API
- Patient Portal Pages Reference - UI documentation