Client Architecture¶
The Metricis client is a jsPsych-based assessment interface for administering cognitive tests.
Overview¶
- Framework: jsPsych 8.x
- Build Tool: Vite 5.x
- Language: TypeScript 5.x
- Mobile: Capacitor 6.x for iOS/Android deployment
- Offline: Dexie.js for local storage
- i18n: i18next for English/French translations
Project Structure¶
client/
├── src/
│ ├── main.ts # Entry point - initializes jsPsych timeline
│ ├── config.ts # Task configuration parameters
│ ├── tasks/ # Cognitive task implementations
│ │ ├── index.ts # Task summaries and orchestration
│ │ ├── simple-rt.ts # Simple reaction time
│ │ ├── cpt.ts # Continuous performance test
│ │ ├── n-back.ts # N-back working memory
│ │ ├── digit-span.ts # Digit span (forward/backward)
│ │ ├── tmt.ts # Trail making test
│ │ ├── flanker.ts # Flanker task
│ │ └── ... # Other cognitive tasks
│ ├── orchestrator/ # Task sequencing and CAT logic
│ │ ├── index.ts # Main orchestrator
│ │ └── cat.ts # Computer adaptive testing
│ ├── services/
│ │ ├── api.ts # API client for backend communication
│ │ └── storage.ts # IndexedDB/Dexie storage
│ ├── plugins/ # Custom jsPsych plugins
│ ├── i18n/ # Translations (en, fr)
│ └── styles/ # CSS styles
├── capacitor.config.ts # Capacitor mobile configuration
├── vite.config.ts # Vite build configuration
└── tsconfig.json # TypeScript configuration
Core Concepts¶
jsPsych Timeline¶
The client builds a linear timeline of trials that jsPsych executes sequentially:
// main.ts
import { initJsPsych } from 'jspsych';
import { createSimpleRTTimeline } from './tasks/simple-rt';
import { createCPTTimeline } from './tasks/cpt';
const jsPsych = initJsPsych({
on_finish: () => {
// Submit data to server
}
});
const timeline = [];
// Welcome
timeline.push(welcomeTrial);
// Task 1: Simple RT
timeline.push(...createSimpleRTTimeline(jsPsych));
// Break
timeline.push(breakTrial);
// Task 2: CPT
timeline.push(...createCPTTimeline(jsPsych));
// Completion
timeline.push(completionTrial);
jsPsych.run(timeline);
Task Pattern¶
Each task exports a createXTimeline(jsPsych) function:
// tasks/simple-rt.ts
import { JsPsych } from 'jspsych';
import { TASK_CONFIG } from '../config';
export function createSimpleRTTimeline(jsPsych: JsPsych) {
const config = TASK_CONFIG.simpleRT;
const instructionsTrial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '<p>Press SPACE as quickly as possible when you see the circle.</p>',
};
const fixationTrial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '+',
choices: 'NO_KEYS',
trial_duration: () => 500 + Math.random() * 500,
};
const testTrial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '<div class="circle"></div>',
choices: [' '],
on_finish: (data) => {
data.task = 'simple_rt';
data.rt = data.rt;
data.correct = data.rt < 5000;
},
};
const timeline = [];
timeline.push(instructionsTrial);
for (let i = 0; i < config.trials; i++) {
timeline.push(fixationTrial);
timeline.push(testTrial);
}
return timeline;
}
Configuration¶
All task parameters are centralized in config.ts:
// config.ts
export const TASK_CONFIG = {
simpleRT: {
trials: 20,
timeout: 5000,
},
cpt: {
trials: 100,
targetFrequency: 0.3,
stimulusDuration: 500,
isi: 1000,
},
nback: {
levels: [1, 2, 3],
trialsPerLevel: 20,
stimulusDuration: 500,
isi: 2000,
},
// ...
};
API Communication¶
The client communicates with the server via a typed API client:
// services/api.ts
export class ApiClient {
private baseUrl: string;
private sessionId: string | null = null;
async startSession(participantId: string): Promise<SessionResponse> {
const response = await fetch(`${this.baseUrl}/session/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ participant_id: participantId }),
});
const data = await response.json();
this.sessionId = data.session_id;
return data;
}
async submitData(taskSummaries: TaskSummary[], trials: Trial[]): Promise<void> {
await fetch(`${this.baseUrl}/submit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Session-ID': this.sessionId || '',
},
body: JSON.stringify({
session_id: this.sessionId,
task_summaries: taskSummaries,
trials: trials,
}),
});
}
}
Data Flow¶
- Session Start: Client calls
/api/session/start→ receives session ID - Task Execution: jsPsych runs tasks, collecting trial data
- Data Storage: Trial data stored in
jsPsych.data.get() - Submission: On completion, client calls
/api/submitwith: - Task summaries (computed metrics like mean RT, accuracy)
- Raw trial data
- Server Processing: Server stores data locally and syncs to REDCap
Offline Support¶
The client uses Dexie.js (IndexedDB wrapper) for offline data storage:
// services/storage.ts
import Dexie, { Table } from 'dexie';
interface Session {
id: string;
participantId: string;
startTime: number;
trials: Trial[];
submitted: boolean;
}
class AssessmentDB extends Dexie {
sessions!: Table<Session>;
constructor() {
super('AssessmentDB');
this.version(1).stores({
sessions: 'id, participantId, submitted',
});
}
}
export const db = new AssessmentDB();
Offline workflow: 1. Store session data locally when offline 2. On reconnection, sync pending sessions to server 3. Mark sessions as submitted after successful sync
Internationalization¶
The client supports multiple languages via i18next:
// i18n/index.ts
import i18next from 'i18next';
import en from './en.json';
import fr from './fr.json';
i18next.init({
lng: navigator.language.split('-')[0], // Browser language
fallbackLng: 'en',
resources: {
en: { translation: en },
fr: { translation: fr },
},
});
export default i18next;
Usage in tasks:
import i18next from '../i18n';
const instructionsTrial = {
stimulus: i18next.t('tasks.simpleRT.instructions'),
};
Mobile Deployment (Capacitor)¶
Configuration¶
// capacitor.config.ts
import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'app.metricis.app',
appName: 'Metricis',
webDir: 'dist',
server: {
// For development - point to local backend
url: process.env.VITE_NATIVE_API_URL || 'http://localhost:8000',
cleartext: true, // Allow HTTP in dev
},
ios: {
contentInset: 'always',
},
android: {
allowMixedContent: true, // For dev only
},
};
export default config;
Build Process¶
# Build web assets
npm run build
# Sync to native platforms
npx cap sync
# Open in Xcode (iOS)
npx cap open ios
# Open in Android Studio
npx cap open android
Native Features¶
Capacitor provides access to native device features:
import { PushNotifications } from '@capacitor/push-notifications';
import { LocalNotifications } from '@capacitor/local-notifications';
import { Network } from '@capacitor/network';
// Push notifications
await PushNotifications.register();
// Network status
const status = await Network.getStatus();
if (!status.connected) {
// Switch to offline mode
}
Development Mode¶
The client includes a dev menu for testing individual tasks:
http://localhost:5173 # Dev menu
http://localhost:5173?task=simple-rt # Run specific task
http://localhost:5173?task=all # Run full battery
http://localhost:5173?results # View results page
Performance Considerations¶
Timing Accuracy¶
jsPsych uses requestAnimationFrame for precise timing:
const testTrial = {
type: jsPsychImageKeyboardResponse,
stimulus: 'target.png',
stimulus_duration: 16.67, // Exactly 1 frame at 60fps
trial_duration: 1000,
};
Data Size¶
- Raw trial data can be large (100s of KB)
- Compress before submission (gzip)
- Submit in batches for long assessments
Battery Life¶
- Minimize background processes
- Reduce screen brightness programmatically if possible
- Warn user to keep device plugged in
Security¶
Data Privacy¶
- No PII stored in client (only participant IDs)
- Data encrypted in transit (HTTPS)
- IndexedDB data cleared after successful submission
Session Security¶
- Session IDs are short-lived (expire after 24 hours)
- No authentication tokens stored in client
- Magic link authentication for patients
Testing¶
Unit Tests¶
Test task logic without running full assessments:
import { calculateSimpleRTSummary } from './tasks/simple-rt';
describe('Simple RT', () => {
it('calculates mean RT correctly', () => {
const trials = [
{ rt: 300, correct: true },
{ rt: 350, correct: true },
{ rt: 250, correct: true },
];
const summary = calculateSimpleRTSummary(trials);
expect(summary.meanRT).toBe(300);
});
});
E2E Tests¶
Playwright can automate full assessment flows:
test('completes simple RT task', async ({ page }) => {
await page.goto('http://localhost:5173?task=simple-rt');
// Wait for instructions
await page.waitForSelector('text=Press SPACE');
await page.keyboard.press('Space');
// Complete trials
for (let i = 0; i < 20; i++) {
await page.waitForSelector('.circle');
await page.keyboard.press('Space');
}
// Check completion
await expect(page.locator('text=Task complete')).toBeVisible();
});
Next Steps¶
- Architecture Overview - System design
- Portal Architecture - Researcher portal
- Patient Portal Architecture - Patient interface
- Server Architecture - Backend API