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 installStep 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 buildCopy 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
| Feature | API Used |
|---|---|
| Commands | context.registerCommand() |
| Keyboard shortcuts | keybinding option |
| Status bar UI | context.layout.addComponent('editor-status-bar', ...) |
| Configuration | configSchema + context.config.get() |
| Cleanup | dispose() pattern |
| Logging | context.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()