Skip to content

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

  1. Session Start: Client calls /api/session/start → receives session ID
  2. Task Execution: jsPsych runs tasks, collecting trial data
  3. Data Storage: Trial data stored in jsPsych.data.get()
  4. Submission: On completion, client calls /api/submit with:
  5. Task summaries (computed metrics like mean RT, accuracy)
  6. Raw trial data
  7. 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