Skip to content

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

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>
  );
}
// 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

// 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