Readied Docs
Plugins

Tutorial: Build a Pomodoro Timer Plugin

Step-by-step tutorial covering commands, status bar components, config, and cleanup

Tutorial: Build a Pomodoro Timer Plugin

Build a complete plugin from scratch. This tutorial covers commands, status bar components, config, and cleanup.

What We're Building

A Pomodoro timer that:

  • Shows a countdown in the status bar
  • Starts/pauses with a keyboard shortcut
  • Has configurable work/break durations
  • Toggles via an editor header button

Step 1: Scaffold the Project

npx readied-plugin init "Pomodoro Timer"
cd pomodoro-timer
npm install

Step 2: Define the Plugin Manifest

Edit src/index.ts:

import type { PluginManifest } from '@readied/plugin-api';

export const plugin: PluginManifest = {
  id: 'pomodoro-timer',
  name: 'Pomodoro Timer',
  version: '0.1.0',
  description: 'Focus timer with work/break cycles',

  configSchema: {
    workMinutes: {
      type: 'range',
      default: 25,
      min: 5,
      max: 60,
      step: 5,
      description: 'Work session duration (minutes)',
    },
    breakMinutes: {
      type: 'range',
      default: 5,
      min: 1,
      max: 15,
      step: 1,
      description: 'Break duration (minutes)',
    },
  },

  activate(context) {
    // We'll fill this in next
    return { dispose() {} };
  },
};

This gives us a plugin with two configurable settings that appear automatically in Settings > Plugins.

Step 3: Add Timer State

We need shared state between the React component (status bar) and the command handler. Use a bridge pattern:

interface TimerState {
  running: boolean;
  secondsLeft: number;
  mode: 'work' | 'break';
}

const state: TimerState = {
  running: false,
  secondsLeft: 25 * 60,
  mode: 'work',
};

const listeners = new Set<() => void>();
function notify() {
  listeners.forEach(fn => fn());
}

Step 4: Create the Status Bar Component

import { useState, useEffect } from 'react';
import type { ZoneComponentProps } from '@readied/plugin-api';

function PomodoroStatus(_props: ZoneComponentProps) {
  const [, forceUpdate] = useState(0);

  useEffect(() => {
    const update = () => forceUpdate(n => n + 1);
    listeners.add(update);
    return () => { listeners.delete(update); };
  }, []);

  const mins = Math.floor(state.secondsLeft / 60);
  const secs = state.secondsLeft % 60;
  const time = `${mins}:${secs.toString().padStart(2, '0')}`;
  const icon = state.mode === 'work' ? '🍅' : '☕';
  const status = state.running ? '' : ' (paused)';

  return (
    <span title={`${state.mode === 'work' ? 'Work' : 'Break'} session`}>
      {icon} {time}{status}
    </span>
  );
}

Step 5: Wire Up the Activate Function

activate(context) {
  let intervalId: ReturnType<typeof setInterval> | null = null;

  // Read config
  const workMins = context.config.get<number>('workMinutes') ?? 25;
  const breakMins = context.config.get<number>('breakMinutes') ?? 5;
  state.secondsLeft = workMins * 60;

  function tick() {
    if (!state.running) return;

    state.secondsLeft--;
    if (state.secondsLeft <= 0) {
      // Switch modes
      if (state.mode === 'work') {
        state.mode = 'break';
        state.secondsLeft = breakMins * 60;
        context.log.info('Break time! Take a rest.');
      } else {
        state.mode = 'work';
        state.secondsLeft = workMins * 60;
        context.log.info('Back to work!');
      }
    }
    notify();
  }

  function startTimer() {
    if (intervalId) return;
    state.running = true;
    intervalId = setInterval(tick, 1000);
    notify();
  }

  function stopTimer() {
    state.running = false;
    if (intervalId) {
      clearInterval(intervalId);
      intervalId = null;
    }
    notify();
  }

  // Add status bar component
  context.layout.addComponent('editor-status-bar', {
    id: 'pomodoro:status',
    component: PomodoroStatus,
    order: 30,
  });

  // Register toggle command
  const unregisterToggle = context.registerCommand(
    {
      id: 'toggle',
      name: 'Toggle Pomodoro Timer',
      keybinding: { key: 'P', modifiers: ['Mod', 'Shift'] },
      icon: 'Timer',
    },
    () => {
      if (state.running) stopTimer();
      else startTimer();
      return true;
    }
  );

  // Register reset command
  const unregisterReset = context.registerCommand(
    {
      id: 'reset',
      name: 'Reset Pomodoro Timer',
      icon: 'RotateCcw',
    },
    () => {
      stopTimer();
      state.mode = 'work';
      state.secondsLeft = workMins * 60;
      notify();
      context.log.info('Timer reset');
      return true;
    }
  );

  return {
    dispose() {
      stopTimer();
      listeners.clear();
      context.layout.removeComponent('pomodoro:status');
      unregisterToggle();
      unregisterReset();
    },
  };
},

Step 6: Build and Test

npm run build

Copy the dist/ folder along with manifest.json to your plugins directory.

Complete Code

Here's the full plugin in one file:

import { useState, useEffect } from 'react';
import type { PluginManifest, ZoneComponentProps } from '@readied/plugin-api';

// --- Shared state ---
interface TimerState {
  running: boolean;
  secondsLeft: number;
  mode: 'work' | 'break';
}

const state: TimerState = { running: false, secondsLeft: 25 * 60, mode: 'work' };
const listeners = new Set<() => void>();
function notify() { listeners.forEach(fn => fn()); }

// --- Status bar component ---
function PomodoroStatus(_props: ZoneComponentProps) {
  const [, forceUpdate] = useState(0);

  useEffect(() => {
    const update = () => forceUpdate(n => n + 1);
    listeners.add(update);
    return () => { listeners.delete(update); };
  }, []);

  const mins = Math.floor(state.secondsLeft / 60);
  const secs = state.secondsLeft % 60;
  const icon = state.mode === 'work' ? '🍅' : '☕';

  return (
    <span>
      {icon} {mins}:{secs.toString().padStart(2, '0')}
      {!state.running && ' (paused)'}
    </span>
  );
}

// --- Plugin ---
export const plugin: PluginManifest = {
  id: 'pomodoro-timer',
  name: 'Pomodoro Timer',
  version: '0.1.0',
  description: 'Focus timer with work/break cycles',

  configSchema: {
    workMinutes: {
      type: 'range', default: 25, min: 5, max: 60, step: 5,
      description: 'Work session duration (minutes)',
    },
    breakMinutes: {
      type: 'range', default: 5, min: 1, max: 15, step: 1,
      description: 'Break duration (minutes)',
    },
  },

  activate(context) {
    let intervalId: ReturnType<typeof setInterval> | null = null;
    const workMins = context.config.get<number>('workMinutes') ?? 25;
    const breakMins = context.config.get<number>('breakMinutes') ?? 5;
    state.secondsLeft = workMins * 60;

    function tick() {
      if (!state.running) return;
      state.secondsLeft--;
      if (state.secondsLeft <= 0) {
        state.mode = state.mode === 'work' ? 'break' : 'work';
        state.secondsLeft = (state.mode === 'work' ? workMins : breakMins) * 60;
        context.log.info(state.mode === 'work' ? 'Back to work!' : 'Break time!');
      }
      notify();
    }

    function start() { state.running = true; intervalId = setInterval(tick, 1000); notify(); }
    function stop() { state.running = false; if (intervalId) clearInterval(intervalId); intervalId = null; notify(); }

    context.layout.addComponent('editor-status-bar', {
      id: 'pomodoro:status', component: PomodoroStatus, order: 30,
    });

    const off1 = context.registerCommand(
      { id: 'toggle', name: 'Toggle Pomodoro', keybinding: { key: 'P', modifiers: ['Mod', 'Shift'] }, icon: 'Timer' },
      () => { state.running ? stop() : start(); return true; }
    );

    const off2 = context.registerCommand(
      { id: 'reset', name: 'Reset Pomodoro', icon: 'RotateCcw' },
      () => { stop(); state.mode = 'work'; state.secondsLeft = workMins * 60; notify(); return true; }
    );

    return { dispose() { stop(); listeners.clear(); context.layout.removeComponent('pomodoro:status'); off1(); off2(); } };
  },
};

What We Covered

FeatureAPI Used
Commandscontext.registerCommand()
Keyboard shortcutskeybinding option
Status bar UIcontext.layout.addComponent('editor-status-bar', ...)
ConfigurationconfigSchema + context.config.get()
Cleanupdispose() pattern
Loggingcontext.log.info()

Next Steps

  • Add an editor header button to toggle the timer
  • Use context.app.onNoteSelected() to auto-pause when switching notes
  • Store session history using context.config.set()