Project 1: Personal Pomodoro Timer

Project 1: Personal Pomodoro Timer

Comprehensive Learning Guide Master Stream Deck plugin development through building a real-time countdown timer with dynamic visual feedback


Table of Contents

  1. Learning Objectives
  2. The Core Question You’re Answering
  3. Deep Theoretical Foundation
  4. Concepts You Must Understand First
  5. Complete Project Specification
  6. Real World Outcome
  7. Solution Architecture
  8. Phased Implementation Guide
  9. Questions to Guide Your Design
  10. Thinking Exercise
  11. Testing Strategy
  12. Common Pitfalls & Debugging
  13. The Interview Questions They’ll Ask
  14. Hints in Layers
  15. Extensions & Challenges
  16. Real-World Connections
  17. Books That Will Help
  18. Self-Assessment Checklist

Metadata

Field Value
Difficulty Level 1: Beginner
Time Estimate Weekend (8-12 hours)
Programming Language JavaScript/TypeScript
Prerequisites Basic JavaScript/TypeScript, Node.js fundamentals
Knowledge Area GUI / Hardware Integration
Software/Tool Elgato Stream Deck SDK
Main Book “The Pragmatic Programmer” (for state machines context)
Coolness Level Level 2: Practical but Forgettable
Business Potential 2. The “Micro-SaaS / Pro Tool”

Learning Objectives

By completing this project, you will master:

  • Plugin Lifecycle Management: Understand the complete Stream Deck plugin lifecycle - from onWillAppear when a key becomes visible to onWillDisappear when it leaves. You’ll know exactly when each event fires and what data is available.

  • WebSocket Communication Architecture: Comprehend how your Node.js plugin communicates bidirectionally with the Stream Deck application through WebSockets. The SDK abstracts this, but understanding the underlying protocol makes debugging infinitely easier.

  • State Machine Design: Implement a clean finite state machine with exactly four states (IDLE, RUNNING, PAUSED, BREAK). Learn why state machines prevent impossible states and make complex logic manageable.

  • Canvas Rendering for Hardware: Generate dynamic 72x72 pixel images using HTML5 Canvas (or node-canvas in Node.js). Understand pixel density, font sizing, and color contrast for tiny LCD screens.

  • Settings Persistence Patterns: Master the setSettings/getSettings API to persist user preferences across plugin restarts. Learn the critical distinction between runtime state and persisted settings.

  • Event-Driven Timer Implementation: Build accurate countdown timers using setInterval while understanding JavaScript’s timing guarantees and limitations. Learn why naive implementations drift over time.

  • Property Inspector Development: Create configuration UIs that communicate with your plugin through the Stream Deck bridge. Handle the peculiar bidirectional communication between main plugin and PI.

  • Real-Time Visual Feedback: Update hardware displays at 1Hz (once per second) without performance degradation. Understand the cost of image generation and network communication.


The Core Question You’re Answering

“How do I make a Stream Deck key update its display dynamically based on internal state, while persisting user preferences across restarts?”

This project answers the fundamental question of real-time visual feedback in Stream Deck plugins. By the end, you’ll understand the complete data flow:

User Input --> State Change --> Visual Update --> Persistence
     ^                                                |
     |________________________________________________|
                    (on restart)

Every professional Stream Deck plugin needs to:

  1. React to user input (key presses)
  2. Maintain internal state
  3. Render visual feedback to the hardware
  4. Persist configuration

The Pomodoro timer combines all four in a single, cohesive project. Once you understand this pattern, you can build any Stream Deck plugin.


Deep Theoretical Foundation

Stream Deck Plugin Architecture

Before writing any code, you must understand how Stream Deck plugins work at a fundamental level. The architecture follows a process separation model where your plugin runs in an isolated Node.js process, communicating with the main Stream Deck application via WebSocket.

+================================================================+
|                    YOUR COMPUTER                                |
+================================================================+
|                                                                 |
|  +---------------------------+    +---------------------------+ |
|  |   STREAM DECK APP         |    |   YOUR PLUGIN             | |
|  |   (Electron/Native)       |    |   (Node.js Process)       | |
|  |                           |    |                           | |
|  |  +---------+  +---------+ |    |  +---------+  +---------+ | |
|  |  | UI      |  | Hardware| |    |  | Action  |  | Property| | |
|  |  | Manager |  | Driver  | |    |  | Handler |  | Inspector| | |
|  |  +---------+  +---------+ |    |  +---------+  +---------+ | |
|  |       |            ^      |    |       ^            ^      | |
|  |       v            |      |    |       |            |      | |
|  |  +---------+       |      |    |       |            |      | |
|  |  | WebSocket|<-----|------|----+-------+------------+      | |
|  |  | Server  |       |      |    |                           | |
|  |  +---------+       |      |    +---------------------------+ |
|  |       ^            |      |                ^                 |
|  |       |            |      |                |                 |
|  +-------|------------|------+                |                 |
|          |            |                       |                 |
|          v            v                       |                 |
|  +---------------------------+                |                 |
|  |   STREAM DECK HARDWARE    |<---------------+                 |
|  |   (Physical Device)       |   (via setImage, setTitle)      |
|  |                           |                                  |
|  |   [Key 0] [Key 1] [Key 2] |                                  |
|  |   [Key 3] [Key 4] [Key 5] |                                  |
|  |   [Key 6] [Key 7] [Key 8] |                                  |
|  +---------------------------+                                  |
|                                                                 |
+================================================================+

Why This Architecture Matters:

  1. Process Isolation: Your plugin cannot crash the Stream Deck app. Bugs in your code only affect your plugin.

  2. Language Freedom: The WebSocket protocol is language-agnostic. While Elgato’s SDK uses Node.js, you could theoretically write plugins in any language.

  3. Hot Reload: The Stream Deck app can restart your plugin without restarting itself. This enables rapid development iteration.

  4. Security: Plugins don’t have direct hardware access. All communication goes through the Stream Deck app, which acts as a gatekeeper.


WebSocket Communication Model

The Stream Deck SDK abstracts WebSocket communication, but understanding the underlying protocol helps with debugging and advanced scenarios.

Plugin Startup Sequence
=======================

1. Stream Deck App launches your plugin process
   |
   v
2. Plugin receives connection info via command-line args:
   - Port number for WebSocket connection
   - UUID to identify this plugin instance
   - Register event name
   - Additional context info
   |
   v
3. Plugin creates WebSocket connection to localhost:{port}
   |
   v
4. Plugin sends registration message:
   {
     "event": "registerPlugin",
     "uuid": "com.example.pomodoro"
   }
   |
   v
5. Stream Deck App acknowledges registration
   |
   v
6. Two-way communication begins


Message Flow (Simplified)
=========================

         Stream Deck App                    Your Plugin
              |                                  |
              |  -- onWillAppear -->             |
              |     {"event": "willAppear",      |
              |      "context": "abc123",        |
              |      "payload": {...}}           |
              |                                  |
              |                                  |
              |  <-- setImage --                 |
              |     {"event": "setImage",        |
              |      "context": "abc123",        |
              |      "payload": {"image": "..."}}|
              |                                  |
              |  -- onKeyDown -->                |
              |     {"event": "keyDown",         |
              |      "context": "abc123"}        |
              |                                  |
              v                                  v

Key Protocol Details:

  • Context: Every action instance has a unique context string. If a user has two Pomodoro timers on their deck, each has a different context. You MUST track state per-context, not globally.

  • Event Names: The SDK uses camelCase for events (willAppear, keyDown), but exposes them as onWillAppear, onKeyDown in your code.

  • Payload: Most events include a payload object with relevant data. For willAppear, this includes current settings. For keyDown, it includes coordinates and whether it’s a multi-action.


Plugin Lifecycle Events

Understanding when each event fires is critical for building reliable plugins. Here’s the complete lifecycle:

Plugin Instance Lifecycle
=========================

                    Stream Deck App
                         |
                         | (User drags action to key)
                         v
              +--------------------+
              |   onWillAppear     |<---- Entry point for new instances
              |                    |      Receive: context, settings, coordinates
              | - Load settings    |      Action: Initialize state, render initial image
              | - Initialize state |
              | - Render initial   |
              +--------------------+
                         |
                         v
              +--------------------+
              |   ACTIVE STATE     |<---- Normal operation
              |                    |
              | onKeyDown -------->|      User pressed key
              | onKeyUp ---------->|      User released key
              | onTitleParamsDidChange -> Title/parameters changed
              | onPropertyInspectorDidAppear -> PI opened
              | onPropertyInspectorDidDisappear -> PI closed
              | onDidReceiveSettings ------> Settings changed (from PI)
              +--------------------+
                         |
                         | (User removes action from key OR
                         |  User navigates to different profile)
                         v
              +--------------------+
              |  onWillDisappear   |<---- Cleanup point
              |                    |      Action: Stop timers, save state
              | - Clear intervals  |
              | - Save final state |
              +--------------------+


Property Inspector Lifecycle
============================

     User clicks gear icon          Your Plugin
              |                          |
              v                          |
    +-------------------+                |
    |   PI Loads        |                |
    | (separate HTML)   |                |
    +-------------------+                |
              |                          |
              | sendToPlugin({...})      |
              |------------------------->|
              |                          | onSendToPlugin
              |                          |
              | <------------------------|
              |   sendToPropertyInspector|
              |                          |
    +-------------------+                |
    |  PI Updates UI    |                |
    +-------------------+                |
              |                          |
              | (User changes settings)  |
              |                          |
              | setSettings({...})       |
              |------------------------->|
              |                          | onDidReceiveSettings
              |                          |
    +-------------------+                |
    | PI Closes         |                |
    | (gear click again)|                |
    +-------------------+                |

Critical Timing Details:

  1. onWillAppear: Fires when a key becomes visible. This happens:
    • When a profile containing your action loads
    • When the user drags your action onto a key
    • When the Stream Deck device connects
    • NOT when the plugin first starts (unless your action is already on a key)
  2. onKeyDown vs onKeyUp: For a Pomodoro timer, you’ll typically use onKeyDown for immediate response. Use onKeyUp if you need to measure press duration (for long-press detection).

  3. onWillDisappear: Fires when the action is removed or hidden. CRITICAL: Stop any setInterval timers here or they’ll continue running and cause errors.

  4. onDidReceiveSettings: Fires whenever settings change, including:
    • When the Property Inspector calls setSettings
    • When onWillAppear includes new settings
    • You should update your internal state and re-render

State Machines for Timer Logic

A Pomodoro timer has exactly four states. Using a state machine prevents “impossible” combinations like being both running and paused simultaneously.

State Machine Diagram
=====================

                        +------------------+
                        |                  |
                        |      IDLE        |
                        |                  |
                        |  Display: START  |
                        |  Timer: Stopped  |
                        |  Background: Gray|
                        +--------+---------+
                                 |
                                 | KEY_DOWN
                                 v
        +------------------------+------------------------+
        |                                                 |
        v                                                 |
+------------------+                                      |
|                  |   KEY_DOWN    +------------------+   |
|    RUNNING       |<------------->|     PAUSED       |   |
|                  |               |                  |   |
|  Display: MM:SS  |               |  Display: MM:SS  |   |
|  Timer: Active   |               |  Timer: Stopped  |   |
|  Background:Green|               |  Background:Yellow|  |
+--------+---------+               +--------+---------+   |
         |                                  |             |
         | TIMER_COMPLETE                   |             |
         v                                  |             |
+------------------+                        |             |
|                  |                        |             |
|     BREAK        |------------------------+             |
|                  |   TIMER_COMPLETE                     |
|  Display: MM:SS  |   or LONG_PRESS                      |
|  Timer: Active   |--------------------------------------+
|  Background: Blue|
+------------------+


State Transition Table
======================

| Current State | Event           | Next State | Actions                      |
|---------------|-----------------|------------|------------------------------|
| IDLE          | KEY_DOWN        | RUNNING    | Start timer, play start sound|
| RUNNING       | KEY_DOWN        | PAUSED     | Stop interval, save time     |
| RUNNING       | TIMER_COMPLETE  | BREAK      | Play alert, start break timer|
| RUNNING       | LONG_PRESS      | IDLE       | Reset to initial state       |
| PAUSED        | KEY_DOWN        | RUNNING    | Resume timer                 |
| PAUSED        | LONG_PRESS      | IDLE       | Reset to initial state       |
| BREAK         | KEY_DOWN        | PAUSED     | Pause break timer            |
| BREAK         | TIMER_COMPLETE  | IDLE/RUN   | Complete cycle, maybe restart|
| BREAK         | LONG_PRESS      | IDLE       | Skip break, reset            |

Why State Machines Work:

  1. Explicit States: Every possible configuration is named and documented. No “halfway” states.

  2. Explicit Transitions: You can’t go from IDLE to BREAK directly. The transition table is the source of truth.

  3. Predictable Actions: Each transition triggers specific actions. This makes debugging easier.

  4. Testable: You can unit test the state machine in isolation from the Stream Deck SDK.

Implementation Pattern:

type TimerState = 'idle' | 'running' | 'paused' | 'break';

type TimerEvent =
  | { type: 'KEY_DOWN' }
  | { type: 'KEY_UP'; duration: number }  // for long-press detection
  | { type: 'TIMER_COMPLETE' };

function transition(state: TimerState, event: TimerEvent): TimerState {
  switch (state) {
    case 'idle':
      if (event.type === 'KEY_DOWN') return 'running';
      return state;

    case 'running':
      if (event.type === 'KEY_DOWN') return 'paused';
      if (event.type === 'TIMER_COMPLETE') return 'break';
      if (event.type === 'KEY_UP' && event.duration > 2000) return 'idle';
      return state;

    // ... etc
  }
}

Reference: The Pragmatic Programmer by Hunt & Thomas, Topic 26, discusses why state machines make code more reliable. Refactoring by Fowler, Chapter 10, shows the State Pattern as an OOP alternative.


Canvas Rendering for Dynamic Images

Stream Deck keys are 72x72 pixel LCD screens. Your plugin generates images as base64-encoded PNGs and sends them via the setImage API.

Canvas Coordinate System
========================

(0,0)--------------------------> X (72)
  |
  |    +-----------------------+
  |    |                       |
  |    |    Your Drawing       |
  |    |    Area               |
  |    |                       |
  |    |                       |
  |    +-----------------------+
  |
  v
  Y (72)


Retina Rendering (2x)
=====================

Physical Key: 72x72 pixels
Canvas Size:  144x144 pixels (2x for retina)

         144 canvas pixels
    +------------------------+
    |                        |
    |    +-----------+       |
144 |    |  Text     |       |  --> Scaled down to
    |    |  renders  |       |      72x72 for display
    |    |  crisply  |       |
    |    +-----------+       |
    +------------------------+


Layout for Pomodoro Timer
=========================

    +------------------------+
    |                        |
    |      +---------+       |
    |      | 24:37   |       | <-- Time: 48px bold font
    |      +---------+       |     Centered at (72, 60)
    |                        |
    |        FOCUS           | <-- Mode: 20px font
    |                        |     Centered at (72, 100)
    |    +----------------+  |
    |    |################|  | <-- Progress bar
    |    +----------------+  |     Rect from (20, 120) to (124, 130)
    +------------------------+

Key Rendering Decisions:

  1. Font Choice: Sans-serif fonts render best at small sizes. Arial, Helvetica, or system fonts work well.

  2. Font Size: For a 144x144 canvas (2x retina):
    • Large numbers: 48-56px
    • Labels: 20-24px
    • Minimum readable: 16px
  3. Contrast: Use high contrast colors. White text on dark backgrounds or vice versa. Avoid subtle gradients.

  4. Progress Visualization: A horizontal progress bar is more readable than a circular one at this size.

Canvas in Node.js:

The Stream Deck plugin runs in Node.js, which doesn’t have a native Canvas API. You have two options:

Option 1: node-canvas (server-side rendering)
=============================================

const { createCanvas } = require('canvas');

const canvas = createCanvas(144, 144);
const ctx = canvas.getContext('2d');

// Draw your timer
ctx.fillStyle = '#2ecc71';
ctx.fillRect(0, 0, 144, 144);

ctx.fillStyle = 'white';
ctx.font = 'bold 48px Arial';
ctx.textAlign = 'center';
ctx.fillText('24:37', 72, 72);

// Convert to base64
const base64 = canvas.toDataURL('image/png');


Option 2: SVG to PNG (if node-canvas won't install)
===================================================

const sharp = require('sharp');

const svg = `
<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg">
  <rect width="144" height="144" fill="#2ecc71"/>
  <text x="72" y="72" font-size="48" fill="white"
        text-anchor="middle" dominant-baseline="middle">
    24:37
  </text>
</svg>
`;

const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
const base64 = 'data:image/png;base64,' + pngBuffer.toString('base64');

Performance Considerations:

  • Rendering at 1Hz (once per second) is fine for a timer
  • Cache static elements (background, icons) if possible
  • The setImage call goes over WebSocket - don’t call it more than needed

Settings Persistence

Stream Deck provides two types of storage, and understanding the difference is crucial:

Settings vs Global Settings
===========================

+-------------------+       +------------------------+
|   Settings        |       |   Global Settings      |
+-------------------+       +------------------------+
| Per-action        |       | Per-plugin             |
| instance          |       | (all instances share)  |
+-------------------+       +------------------------+
| Use for:          |       | Use for:               |
| - Work duration   |       | - API keys             |
| - Break duration  |       | - User preferences     |
| - Sound selection |       | - Shared resources     |
+-------------------+       +------------------------+
| API:              |       | API:                   |
| setSettings()     |       | setGlobalSettings()    |
| ev.payload.settings|       | getGlobalSettings()    |
+-------------------+       +------------------------+


Data Flow: Settings Lifecycle
=============================

    Plugin Code              Stream Deck App           Disk Storage
         |                         |                        |
         |  setSettings({         |                        |
         |    workMinutes: 25     |  --- writes to --->    |
         |  })                    |                [profile.json]
         |                        |                        |
         |                        |                        |
   (plugin restarts)              |                        |
         |                        |                        |
         | <-- onWillAppear ------|                        |
         |     payload.settings = |  <--- reads from ---   |
         |     { workMinutes: 25 }|                [profile.json]
         |                        |                        |


What to Store Where
===================

SETTINGS (per-instance):          RUNTIME STATE (in memory):
- workMinutes                     - currentState (idle/running/etc)
- breakMinutes                    - remainingSeconds
- longBreakMinutes                - intervalId
- sessionsBeforeLongBreak         - sessionCount (or store in settings
- soundEnabled                      if you want to preserve across restart)
- soundFile
- autoStartBreak
- autoStartWork

GLOBAL SETTINGS (per-plugin):
- defaultSoundVolume
- analyticsEnabled
- colorTheme

Critical Persistence Patterns:

  1. Load Settings in onWillAppear:
    onWillAppear(ev: WillAppearEvent<PomodoroSettings>) {
      // Settings come with the event!
      this.settings = ev.payload.settings ?? DEFAULT_SETTINGS;
      this.remainingSeconds = this.settings.workMinutes * 60;
      this.render();
    }
    
  2. Save Settings from Property Inspector: ```typescript // Property Inspector (HTML/JS) document.getElementById(‘save’).addEventListener(‘click’, () => { $SD.setSettings({ workMinutes: parseInt(workInput.value), breakMinutes: parseInt(breakInput.value), }); });

// Plugin receives update onDidReceiveSettings(ev: DidReceiveSettingsEvent) { this.settings = ev.payload.settings; // If timer is idle, update remaining time if (this.state === 'idle') { this.remainingSeconds = this.settings.workMinutes * 60; this.render(); } }


3. **Don't Persist Runtime State** (usually):
```typescript
// BAD: Persisting timer state
setSettings({
  remainingSeconds: 847,  // Don't do this!
  isRunning: true,        // Don't do this!
});

// WHY BAD:
// - Timer state becomes stale quickly
// - What if the user has multiple computers?
// - What if settings sync causes conflicts?

// GOOD: Only persist configuration
setSettings({
  workMinutes: 25,
  breakMinutes: 5,
});

Concepts You Must Understand First

Before writing any code, ensure you understand these foundations:

Concept Why It Matters Where to Learn
Plugin Lifecycle Events onWillAppear and onKeyDown are your entry points for all interaction. Without understanding when these fire, your timer will behave unpredictably. Stream Deck SDK docs, Node.js Design Patterns Ch. 3
State Machines A Pomodoro timer has distinct states (IDLE, RUNNING, PAUSED, BREAK). State machines prevent “impossible” states like being paused and running simultaneously. The Pragmatic Programmer Topic 26, Refactoring Ch. 10
Canvas Rendering Basics Stream Deck keys are 72x72 pixel images. You’ll generate these dynamically using node-canvas or SVG conversion. MDN Canvas Tutorial, node-canvas docs
Settings Persistence User preferences must survive plugin restarts. Stream Deck provides setSettings/getSettings for this. Stream Deck SDK Settings Guide
setInterval/Timers in Node.js Your countdown runs via JavaScript timers. Understand how they interact with the event loop. Node.js Design Patterns Ch. 2
TypeScript Generics The SDK uses generics for type-safe settings. WillAppearEvent<YourSettings> gives you typed access to payload. Effective TypeScript Items 29-37

Complete Project Specification

Functional Requirements

Core Features (Must Have):

Feature Description Priority
Timer countdown display Show remaining time on key in MM:SS format P0
Start/Pause toggle Single press starts or pauses timer P0
Reset functionality Long press (2s) resets timer to initial state P0
Work/Break modes Timer alternates between work and break periods P0
Visual state indication Different colors/icons for each state P0
Configurable durations User sets work/break times in Property Inspector P1
Sound notifications Audio alert when timer completes P1
Progress visualization Visual progress bar on key P1
Auto-start options Optionally auto-start break after work P2
Session counting Track completed Pomodoros for long breaks P2

Timer State Requirements:

interface PomodoroSettings {
  workMinutes: number;        // Default: 25
  breakMinutes: number;       // Default: 5
  longBreakMinutes: number;   // Default: 15
  sessionsBeforeLongBreak: number;  // Default: 4
  soundEnabled: boolean;      // Default: true
  soundFile: string;          // Default: 'chime.mp3'
  autoStartBreak: boolean;    // Default: true
  autoStartWork: boolean;     // Default: false
}

interface RuntimeState {
  state: 'idle' | 'running' | 'paused' | 'break';
  remainingSeconds: number;
  completedSessions: number;
  intervalId: NodeJS.Timeout | null;
}

Non-Functional Requirements

Requirement Target Rationale
Update frequency 1 Hz (once per second) Human-readable countdown, not wasteful
Image render time < 50ms No visible lag when updating display
Memory usage < 50MB Plugins run indefinitely; must be lean
Timer drift < 1 second per hour Use timestamp-based calculation, not counter
CPU usage < 1% idle, < 5% active Don’t drain laptop battery

Real World Outcome

When you complete this project, here’s exactly what you’ll see:

Stream Deck Key Display

Stream Deck Key (72x72 pixels):
+---------------------+
|                     |
|     +---------+     |
|     |  24:37  |     |  <-- Time remaining in large text
|     +---------+     |
|       FOCUS         |  <-- Current mode (FOCUS/BREAK)
|     *--------o      |  <-- Progress bar
+---------------------+

States:
- IDLE:    Shows "START" with tomato icon, gray background
- RUNNING: Shows countdown "MM:SS", green border, progress bar fills
- PAUSED:  Shows paused time, yellow background, pulsing effect
- BREAK:   Shows break countdown, blue background, coffee icon

Key States Visual Progression

+------------------------------------------------------------------+
|                    YOUR STREAM DECK KEY                           |
|                                                                   |
|   IDLE STATE          WORKING STATE         BREAK STATE          |
|   +----------+        +----------+         +----------+          |
|   |  START   |        |  24:37   |         |  04:22   |          |
|   |   :)     |  --->  | ######## |   --->  | ######oo |          |
|   |  25:00   |        |   WORK   |         |  BREAK   |          |
|   +----------+        +----------+         +----------+          |
|    (Gray)              (Red/Orange)         (Green/Blue)          |
+------------------------------------------------------------------+

Property Inspector UI

When you click the gear icon for your action, this configuration panel appears:

+---------------------------------------------+
|  Pomodoro Timer Settings                    |
+---------------------------------------------+
|  Work Duration:    [25] minutes             |
|  Break Duration:   [5 ] minutes             |
|  Long Break:       [15] minutes             |
|  Sessions Before Long Break: [4]            |
|                                             |
|  [x] Play sound on completion               |
|  [x] Auto-start break after work            |
|  [ ] Auto-start work after break            |
|                                             |
|  Sound: [Chime        v]                    |
+---------------------------------------------+

Interaction Flow

Interaction Timeline
====================

Time     Action                  Display           State
----     ------                  -------           -----
0:00     User presses key        "25:00" green     IDLE -> RUNNING
0:01     -                       "24:59"           RUNNING
...
24:00    -                       "01:00"           RUNNING
24:59    -                       "00:01"           RUNNING
25:00    Timer completes!        "BREAK" blue      RUNNING -> BREAK
         Sound plays
         Break timer starts      "05:00"
...
30:00    Break completes         "START" gray      BREAK -> IDLE
         (or auto-starts work)


Long Press Detection
====================

Time     Action                  Result
----     ------                  ------
0:00     User presses key        KEY_DOWN fires
0:50     -                       (still held)
2:00     User releases key       KEY_UP fires with duration=2000ms
                                 State: ANY -> IDLE (reset)

Solution Architecture

System Architecture Diagram

+==================================================================+
|                    POMODORO TIMER PLUGIN                          |
+==================================================================+
|                                                                   |
|  +------------------+     +------------------+     +------------+ |
|  |  Action Class    |     |  Timer Engine    |     |  Renderer  | |
|  |------------------|     |------------------|     |------------| |
|  | - onWillAppear   |---->| - startTimer     |---->| - drawIdle | |
|  | - onKeyDown      |     | - pauseTimer     |     | - drawRun  | |
|  | - onKeyUp        |     | - resetTimer     |     | - drawPause| |
|  | - onWillDisappear|<----| - tick           |<----| - drawBreak| |
|  +------------------+     +------------------+     +------------+ |
|           |                       |                      |        |
|           |                       v                      |        |
|           |               +------------------+           |        |
|           |               |  State Machine   |           |        |
|           |               |------------------|           |        |
|           +-------------->| - currentState   |<----------+        |
|                           | - transition()   |                    |
|                           | - canTransition()|                    |
|                           +------------------+                    |
|                                   |                               |
|                                   v                               |
|  +------------------+     +------------------+                    |
|  | Property Inspector|     |  Sound Player   |                    |
|  |------------------|     |------------------|                    |
|  | - settings form  |     | - playChime     |                    |
|  | - save handler   |     | - playAlert     |                    |
|  | - defaults       |     | - setVolume     |                    |
|  +------------------+     +------------------+                    |
|                                                                   |
+==================================================================+
                                   |
                                   v WebSocket (via SDK)
+==================================================================+
|                    STREAM DECK APPLICATION                        |
+==================================================================+
|                                                                   |
|  Receives: setImage, setTitle, setSettings                       |
|  Sends: onWillAppear, onKeyDown, onKeyUp, onDidReceiveSettings   |
|                                                                   |
+==================================================================+
                                   |
                                   v USB/Hardware
+==================================================================+
|                    STREAM DECK DEVICE                             |
+==================================================================+

Class Diagram

+----------------------------------+
|        PomodoroAction            |
+----------------------------------+
| - context: string                |
| - state: TimerState              |
| - remainingSeconds: number       |
| - intervalId: Timeout | null     |
| - settings: PomodoroSettings     |
| - pressStartTime: number | null  |
+----------------------------------+
| + onWillAppear(ev): void         |
| + onWillDisappear(ev): void      |
| + onKeyDown(ev): void            |
| + onKeyUp(ev): void              |
| + onDidReceiveSettings(ev): void |
+----------------------------------+
| - startTimer(): void             |
| - pauseTimer(): void             |
| - resetTimer(): void             |
| - tick(): void                   |
| - transitionToBreak(): void      |
| - transitionToIdle(): void       |
| - render(): void                 |
| - playSound(): void              |
+----------------------------------+
           |
           | uses
           v
+----------------------------------+
|        TimerRenderer             |
+----------------------------------+
| - canvas: Canvas                 |
| - ctx: CanvasRenderingContext2D  |
+----------------------------------+
| + renderIdle(settings): string   |
| + renderRunning(secs, total): str|
| + renderPaused(secs, total): str |
| + renderBreak(secs, total): str  |
+----------------------------------+
| - drawBackground(color): void    |
| - drawTime(secs): void           |
| - drawLabel(text): void          |
| - drawProgress(pct): void        |
| - toBase64(): string             |
+----------------------------------+

+----------------------------------+
|      PomodoroSettings            |
+----------------------------------+
| workMinutes: number              |
| breakMinutes: number             |
| longBreakMinutes: number         |
| sessionsBeforeLongBreak: number  |
| soundEnabled: boolean            |
| soundFile: string                |
| autoStartBreak: boolean          |
| autoStartWork: boolean           |
+----------------------------------+

+----------------------------------+
|         TimerState               |
+----------------------------------+
| 'idle' | 'running' |             |
| 'paused' | 'break'               |
+----------------------------------+

File Structure

pomodoro-timer/
+-- package.json              # Dependencies: @elgato/streamdeck, canvas
+-- tsconfig.json             # TypeScript config
+-- rollup.config.js          # Bundler config (or esbuild)
|
+-- src/
|   +-- plugin.ts             # Entry point, registers action
|   +-- actions/
|   |   +-- pomodoro.ts       # Main PomodoroAction class
|   +-- core/
|   |   +-- state-machine.ts  # State transitions
|   |   +-- timer-engine.ts   # setInterval wrapper
|   |   +-- renderer.ts       # Canvas rendering
|   |   +-- sound.ts          # Audio playback
|   +-- types/
|       +-- settings.ts       # PomodoroSettings interface
|       +-- events.ts         # Event type extensions
|
+-- pi/                       # Property Inspector
|   +-- index.html            # PI markup
|   +-- index.js              # PI logic
|   +-- style.css             # PI styling
|
+-- assets/
|   +-- images/
|   |   +-- action-icon.png   # Default action icon
|   |   +-- action-icon@2x.png
|   +-- sounds/
|       +-- chime.mp3         # Notification sound
|       +-- bell.mp3
|
+-- com.example.pomodoro.sdPlugin/  # Built plugin folder
    +-- manifest.json         # Plugin manifest
    +-- bin/                  # Compiled JS
    +-- pi/                   # Property Inspector files
    +-- images/               # Icons
    +-- sounds/               # Audio files

Phased Implementation Guide

Phase 1: Plugin Scaffolding (Day 1, Morning)

Goal: Create a plugin that loads in Stream Deck and shows a static icon.

Milestone: Plugin appears in Stream Deck app, action can be dragged to a key.

Steps:

  1. Install Stream Deck CLI:
    npm install -g @elgato/cli
    
  2. Create new plugin:
    streamdeck create pomodoro-timer
    cd pomodoro-timer
    
  3. Examine the generated structure:
    • Open manifest.json - this defines your plugin
    • Open src/plugin.ts - this is your entry point
    • Open the action file created by the template
  4. Build and link for development:
    npm run build
    streamdeck link  # Creates symlink for hot reload
    
  5. Verify in Stream Deck app:
    • Open Stream Deck app
    • Find your plugin in the action list
    • Drag it to a key
    • Observe the console output

Success Criteria:

  • Plugin loads without errors
  • Action appears in Stream Deck
  • Dragging to key triggers onWillAppear (check console)

Phase 2: Static Display (Day 1, Afternoon)

Goal: Render a custom image on the key showing “START” and a time.

Milestone: Key displays “START / 25:00” instead of default icon.

Steps:

  1. Install canvas dependency:
    npm install canvas
    # If canvas won't build, try: npm install sharp
    
  2. Create a basic renderer (src/core/renderer.ts):
    • Create 144x144 canvas (2x for retina)
    • Draw gray background
    • Draw “START” text centered
    • Draw “25:00” below
    • Export as base64 PNG
  3. Update action to use renderer:
    • In onWillAppear, call your renderer
    • Call ev.action.setImage(base64String)
  4. Test:
    • Build and reload plugin
    • Drag action to key
    • Observe custom image

Hints:

  • Canvas text alignment: ctx.textAlign = 'center'
  • Canvas baseline: ctx.textBaseline = 'middle'
  • To center at (72, 72), use: ctx.fillText('TEXT', 72, 72)

Success Criteria:

  • Key shows your custom-rendered image
  • Text is readable and centered
  • No console errors

Phase 3: Timer Logic (Day 2, Morning)

Goal: Implement countdown timer that updates the display every second.

Milestone: Pressing key starts countdown, display updates in real-time.

Steps:

  1. Add state tracking to your action:
    private state: 'idle' | 'running' = 'idle';
    private remainingSeconds: number = 25 * 60;
    private intervalId: NodeJS.Timeout | null = null;
    
  2. Implement onKeyDown:
    • If idle: start timer, set state to running
    • If running: pause timer (for now, just stop)
  3. Create tick function:
    • Decrement remainingSeconds
    • Re-render display
    • If remainingSeconds === 0: stop timer, reset
  4. Wire up the interval:
    private startTimer() {
      this.state = 'running';
      this.intervalId = setInterval(() => this.tick(), 1000);
      this.render();
    }
    
  5. Test:
    • Press key: timer starts
    • Watch countdown: updates every second
    • Wait for completion: timer resets

Critical Bug Prevention:

  • Clear interval in onWillDisappear:
    onWillDisappear() {
      if (this.intervalId) {
        clearInterval(this.intervalId);
        this.intervalId = null;
      }
    }
    

Success Criteria:

  • Timer starts on key press
  • Display updates every second
  • Timer resets when complete
  • No zombie intervals after removing action

Phase 4: State Machine (Day 2, Afternoon)

Goal: Implement full state machine with pause, break, and reset.

Milestone: All four states work correctly with proper transitions.

Steps:

  1. Expand state type:
    type TimerState = 'idle' | 'running' | 'paused' | 'break';
    
  2. Implement state transitions:
    • idle + KEY_DOWN = running
    • running + KEY_DOWN = paused
    • paused + KEY_DOWN = running (resume)
    • running + COMPLETE = break
    • break + COMPLETE = idle
  3. Add long-press detection:
    • Track keyDownTime in onKeyDown
    • Check duration in onKeyUp
    • If > 2 seconds: reset to idle
  4. Update renderer for all states:
    • Idle: gray background, “START”
    • Running: green background, countdown
    • Paused: yellow background, “PAUSED”
    • Break: blue background, “BREAK”
  5. Test all transitions:
    • Press: idle -> running
    • Press: running -> paused
    • Press: paused -> running
    • Wait: running -> break
    • Long press from any state: -> idle

Success Criteria:

  • All state transitions work
  • Visual states are distinct
  • Long press resets from any state
  • No impossible state combinations

Phase 5: Property Inspector (Day 3, Morning)

Goal: Create settings UI for configuring timer durations.

Milestone: User can change work/break durations; settings persist.

Steps:

  1. Create Property Inspector HTML (pi/index.html):
    <div class="sdpi-wrapper">
      <div class="sdpi-item">
        <div class="sdpi-item-label">Work (minutes)</div>
        <input id="work" class="sdpi-item-value" type="number" min="1" max="60">
      </div>
      <!-- More fields... -->
    </div>
    
  2. Add Property Inspector JS (pi/index.js):
    • Load current settings from $SD.settings
    • Populate form fields
    • On change, call $SD.setSettings()
  3. Handle settings in plugin:
    • Implement onDidReceiveSettings
    • Update internal settings object
    • If idle, update remaining time
  4. Test:
    • Open PI (click gear icon)
    • Change work minutes
    • Close PI
    • Verify timer uses new duration

Hints:

  • Use the Stream Deck SDK’s PI library: Include sdpi.css and $SD global
  • Settings are automatically saved to profile
  • Test by restarting Stream Deck app

Success Criteria:

  • PI loads and shows current settings
  • Changing settings updates plugin behavior
  • Settings persist across restarts

Phase 6: Sound Notifications (Day 3, Afternoon)

Goal: Play audio alerts when timer completes.

Milestone: Chime plays when work/break period ends.

Steps:

  1. Add sound files:
    • Place chime.mp3 in assets/sounds/
    • Ensure it’s copied to build output
  2. Install audio player:
    npm install play-sound
    # or: npm install node-wav-player
    
  3. Create sound module (src/core/sound.ts):
    import player from 'play-sound';
    
    export function playNotification(soundFile: string) {
      const soundPath = path.join(__dirname, 'sounds', soundFile);
      player().play(soundPath, (err) => {
        if (err) console.error('Sound failed:', err);
      });
    }
    
  4. Wire up to state transitions:
    • When RUNNING -> BREAK: play work complete sound
    • When BREAK -> IDLE: play break complete sound
  5. Add sound setting to PI:
    • Checkbox for enable/disable
    • Dropdown for sound selection

Alternative (simpler but hacky):

  • Use system notification with sound via node-notifier

Success Criteria:

  • Sound plays when timer completes
  • Sound can be disabled in settings
  • Different sounds for work/break

Phase 7: Polish and Edge Cases (Day 4)

Goal: Handle all edge cases, improve visuals, optimize performance.

Milestone: Production-ready plugin.

Steps:

  1. Handle multiple instances:
    • Each action has unique context
    • Store state per-context, not globally
    • Test with two Pomodoro actions
  2. Optimize rendering:
    • Don’t re-render if nothing changed
    • Reuse canvas instance
    • Profile with console.time
  3. Add progress bar:
    • Calculate: progress = 1 - (remaining / total)
    • Draw filled rectangle proportional to progress
  4. Improve visual feedback:
    • Add pulsing effect when paused
    • Add celebration animation on completion
    • Color transitions between states
  5. Error handling:
    • Wrap all async operations in try/catch
    • Log errors with context
    • Graceful degradation if sound fails
  6. Test edge cases:
    • Remove action while timer running
    • Restart Stream Deck app mid-timer
    • Set very short durations (1 min)
    • Set very long durations (60 min)

Success Criteria:

  • Multiple instances work independently
  • No memory leaks over time
  • All error cases handled gracefully
  • Professional visual appearance

Questions to Guide Your Design

Before coding, answer these questions on paper:

  1. State Tracking: How will you track timer state between key presses?
    • Consider: class instance variables vs. Stream Deck settings
    • What survives a plugin restart? What should?
  2. Multi-Instance: What if the user drags two Pomodoro actions onto different keys?
    • Should they share state or be independent?
    • How do you store per-instance data?
  3. Timer Accuracy: JavaScript’s setInterval isn’t perfectly accurate.
    • How do you prevent drift over a 25-minute period?
    • Should you store the target end time instead of decrementing?
  4. Break Transitions: How will you handle work/break transitions?
    • Should breaks start automatically?
    • What if the user wants to skip a break?
  5. Long Press: How do you detect a long press (2+ seconds)?
    • When does onKeyDown fire vs onKeyUp?
    • Where do you store the press start time?
  6. Settings vs State: What belongs in persisted settings vs runtime state?
    • Should remainingSeconds persist?
    • What happens if settings sync across computers?
  7. Error Recovery: What happens if your timer callback throws?
    • Does the timer stop? Can you recover?
    • Should you wrap callbacks in try/catch?

Thinking Exercise

Before writing code, trace through this scenario mentally:

SCENARIO: User has a 25-minute Pomodoro timer configured.
They work for 10 minutes, pause for 5 minutes (coffee break), then resume.

Time 0:00  - User presses key
             -> What event fires? _______________
             -> What state do you enter? _______________
             -> What does the key display? _______________
             -> What does your code do with setInterval? _______________

Time 10:00 - User presses key to pause
             -> Timer shows "15:00" remaining
             -> What do you save? _______________
             -> What happens to setInterval? _______________
             -> What color is the key background? _______________

Time 15:00 - User presses key to resume (after 5 min pause)
             -> Should the timer still show "15:00"? _______________
             -> How do you restart the countdown? _______________
             -> Does `remainingSeconds` change? _______________

Time 30:00 - Timer reaches 00:00 (15 min later, so 30 total elapsed)
             -> What event triggers (internal)? _______________
             -> What sound plays? _______________
             -> What state do you transition to? _______________
             -> Does break timer start automatically? _______________

Answers to verify your understanding:

  1. Time 0:00: onKeyDown fires; enter running; display “25:00”; start setInterval
  2. Time 10:00: save remainingSeconds = 900; clear interval; background yellow
  3. Time 15:00: yes, 15:00; start new interval; remainingSeconds unchanged
  4. Time 30:00: internal tick sees 0; play completion sound; transition to break; depends on autoStartBreak setting

Testing Strategy

Unit Tests: State Machine

Test state transitions in isolation:

// tests/state-machine.test.ts
import { describe, it, expect } from 'vitest';
import { transition, TimerState, TimerEvent } from '../src/core/state-machine';

describe('Timer State Machine', () => {
  it('transitions from idle to running on key down', () => {
    const result = transition('idle', { type: 'KEY_DOWN' });
    expect(result).toBe('running');
  });

  it('transitions from running to paused on key down', () => {
    const result = transition('running', { type: 'KEY_DOWN' });
    expect(result).toBe('paused');
  });

  it('transitions from running to break on timer complete', () => {
    const result = transition('running', { type: 'TIMER_COMPLETE' });
    expect(result).toBe('break');
  });

  it('transitions from any state to idle on long press', () => {
    const states: TimerState[] = ['running', 'paused', 'break'];
    for (const state of states) {
      const result = transition(state, { type: 'LONG_PRESS' });
      expect(result).toBe('idle');
    }
  });

  it('stays in idle on long press', () => {
    const result = transition('idle', { type: 'LONG_PRESS' });
    expect(result).toBe('idle');
  });
});

Unit Tests: Timer Calculation

Test time formatting and progress calculation:

// tests/timer-utils.test.ts
describe('Timer Utilities', () => {
  it('formats seconds as MM:SS', () => {
    expect(formatTime(1500)).toBe('25:00');
    expect(formatTime(90)).toBe('01:30');
    expect(formatTime(5)).toBe('00:05');
  });

  it('calculates progress percentage', () => {
    expect(calculateProgress(1500, 1500)).toBe(0);   // Just started
    expect(calculateProgress(750, 1500)).toBe(0.5);  // Halfway
    expect(calculateProgress(0, 1500)).toBe(1);      // Complete
  });

  it('handles edge case of 0 total', () => {
    expect(calculateProgress(0, 0)).toBe(0);
  });
});

Integration Tests: Mocked SDK

Test action behavior without real Stream Deck:

// tests/pomodoro-action.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock the SDK
const mockSetImage = vi.fn();
const mockContext = 'test-context-123';

const mockEvent = {
  action: {
    setImage: mockSetImage,
    setSettings: vi.fn(),
  },
  payload: {
    settings: { workMinutes: 25, breakMinutes: 5 },
  },
};

describe('PomodoroAction', () => {
  let action: PomodoroAction;

  beforeEach(() => {
    vi.clearAllMocks();
    action = new PomodoroAction();
    action.onWillAppear(mockEvent);
  });

  it('renders idle state on appear', () => {
    expect(mockSetImage).toHaveBeenCalled();
    const imageData = mockSetImage.mock.calls[0][0];
    expect(imageData).toContain('data:image/png;base64');
  });

  it('starts timer on key down from idle', () => {
    action.onKeyDown(mockEvent);
    expect(action.getState()).toBe('running');
  });

  it('pauses timer on key down from running', () => {
    action.onKeyDown(mockEvent); // idle -> running
    action.onKeyDown(mockEvent); // running -> paused
    expect(action.getState()).toBe('paused');
  });
});

Manual Testing Checklist

[ ] Plugin loads without console errors
[ ] Action appears in Stream Deck action list
[ ] Dragging to key shows custom image
[ ] Key press starts timer
[ ] Timer updates every second
[ ] Key press pauses timer
[ ] Key press resumes paused timer
[ ] Long press (2s+) resets timer
[ ] Timer transitions to break at 0:00
[ ] Sound plays on completion
[ ] Break timer counts down
[ ] Settings persist across restarts
[ ] Property Inspector loads
[ ] Changing settings updates timer
[ ] Multiple instances work independently
[ ] Removing action stops its timer
[ ] No memory leaks (check after 1 hour)

Common Pitfalls & Debugging

Pitfall 1: Zombie Intervals

Symptom: Timer continues running after action is removed; console errors about missing context.

Cause: Forgetting to clear setInterval in onWillDisappear.

Bad:

// No cleanup!
onWillDisappear() {
  // Empty or missing
}

Good:

onWillDisappear() {
  if (this.intervalId) {
    clearInterval(this.intervalId);
    this.intervalId = null;
  }
}

Debug: Add logging to tick() to see if it fires after removal.


Pitfall 2: Timer Drift

Symptom: After 25 minutes, the timer is off by 5-30 seconds.

Cause: Naively decrementing a counter instead of calculating from timestamps.

Bad:

// Drifts over time!
private tick() {
  this.remainingSeconds--;
  this.render();
}

Good:

private startTime: number;
private duration: number;

startTimer() {
  this.startTime = Date.now();
  this.duration = this.settings.workMinutes * 60 * 1000;
  this.intervalId = setInterval(() => this.tick(), 1000);
}

private tick() {
  const elapsed = Date.now() - this.startTime;
  const remaining = Math.max(0, this.duration - elapsed);
  this.remainingSeconds = Math.ceil(remaining / 1000);
  this.render();

  if (remaining <= 0) {
    this.onTimerComplete();
  }
}

Pitfall 3: Shared State Between Instances

Symptom: Two Pomodoro buttons on the deck control the same timer.

Cause: Using class-level static variables or singleton patterns.

Bad:

// WRONG: Shared across all instances!
@action({ UUID: '...' })
class PomodoroAction {
  static state = 'idle';  // Static = shared
  static remainingSeconds = 0;
}

Good:

// CORRECT: Instance variables
@action({ UUID: '...' })
class PomodoroAction {
  private state = 'idle';  // Per-instance
  private remainingSeconds = 0;
}

Alternative (for complex state):

// Store per-context
const contextState = new Map<string, TimerState>();

onKeyDown(ev) {
  const state = contextState.get(ev.context) ?? defaultState;
  // Use state
}

Pitfall 4: Canvas Not Installing

Symptom: npm install canvas fails with native build errors.

Cause: node-canvas requires native dependencies (Cairo, Pango).

Workarounds:

  1. Install system dependencies (Mac):
    brew install pkg-config cairo pango libpng jpeg giflib librsvg
    
  2. Use prebuilt binary:
    npm install canvas --build-from-source=false
    
  3. Use SVG + Sharp instead:
    const sharp = require('sharp');
    
    function renderTimer(seconds: number): Promise<string> {
      const svg = `<svg>...</svg>`;
      const buffer = await sharp(Buffer.from(svg)).png().toBuffer();
      return 'data:image/png;base64,' + buffer.toString('base64');
    }
    

Pitfall 5: Property Inspector Not Communicating

Symptom: Settings changes in PI don’t affect the plugin.

Cause: Not implementing onDidReceiveSettings or wrong event handling.

Debug Steps:

  1. Add logging to onDidReceiveSettings:
    onDidReceiveSettings(ev) {
      console.log('Received settings:', ev.payload.settings);
    }
    
  2. Add logging to PI:
    $SD.on('connected', () => {
      console.log('PI connected');
    });
    
  3. Check manifest.json has correct PI path:
    "Actions": [{
      "PropertyInspectorPath": "pi/index.html"
    }]
    

Pitfall 6: Sound Not Playing

Symptom: No audio when timer completes.

Possible Causes:

  1. Wrong path: Sound file not in the right location
    // Debug: log the path
    const soundPath = path.join(__dirname, '..', 'sounds', 'chime.mp3');
    console.log('Sound path:', soundPath);
    console.log('Exists:', fs.existsSync(soundPath));
    
  2. File not bundled: Add to your build config
    // rollup.config.js
    copy({
      targets: [{ src: 'assets/sounds/*', dest: 'dist/sounds' }]
    })
    
  3. Audio library issue: Try different library
    npm install afplay  # Mac-specific, simpler
    

Pitfall 7: Long Press Not Detecting

Symptom: Long press doesn’t reset timer.

Cause: Only handling onKeyDown, not onKeyUp.

Solution:

private keyDownTime: number | null = null;

onKeyDown() {
  this.keyDownTime = Date.now();
}

onKeyUp() {
  if (this.keyDownTime) {
    const duration = Date.now() - this.keyDownTime;
    if (duration > 2000) {
      this.resetTimer();
    }
    this.keyDownTime = null;
  }
}

Note: Stream Deck also fires onKeyDown repeatedly during hold. You may want to debounce.


The Interview Questions They’ll Ask

After completing this project, you should be able to answer:

1. “What is the Stream Deck plugin lifecycle?”

Expected Answer Structure:

  • Plugin runs as separate Node.js process
  • Communicates via WebSocket with Stream Deck app
  • Key events: onWillAppear (key becomes visible), onKeyDown/onKeyUp (user interaction), onWillDisappear (cleanup)
  • Settings events: onDidReceiveSettings for updates
  • Must cleanup resources (intervals, listeners) in onWillDisappear

2. “How do you persist state between plugin restarts?”

Expected Answer Structure:

  • Use setSettings() for per-action configuration
  • Use setGlobalSettings() for plugin-wide preferences
  • Settings are stored in Stream Deck profile files
  • Distinguish between config (persisted) and runtime state (not persisted)
  • Receive saved settings in onWillAppear via ev.payload.settings

3. “Explain how WebSocket communication works in Stream Deck plugins.”

Expected Answer Structure:

  • Plugin receives port and UUID via command-line args at startup
  • Opens WebSocket connection to localhost on that port
  • Sends registration message with plugin UUID
  • Bidirectional JSON messages for events and commands
  • SDK abstracts this, but understanding helps debugging
  • Each action instance has unique “context” identifier

4. “How would you test a Stream Deck plugin?”

Expected Answer Structure:

  • Unit tests for pure logic (state machine, time calculations)
  • Mock the SDK for action behavior tests
  • Integration tests with real Stream Deck in dev mode
  • Manual testing checklist for all interactions
  • Use streamdeck link for rapid iteration
  • Check for memory leaks over extended runs

5. “Your timer drifts by a few seconds over an hour. Why?”

Expected Answer Structure:

  • setInterval timing isn’t guaranteed precise
  • JavaScript event loop can delay callback execution
  • Fix: Store target end time, calculate remaining on each tick
  • Use Date.now() instead of decrementing counter
  • Consider requestAnimationFrame for visual updates (in browser context)

6. “How do you handle multiple instances of the same action?”

Expected Answer Structure:

  • Each instance has unique “context” string
  • Store state per-context, not globally
  • Avoid static/class-level variables
  • Map<context, state> pattern for complex cases
  • Each instance should operate independently

Hints in Layers

If you get stuck, reveal hints progressively:

Hint 1: Timer State Management (click to reveal)

Your action class needs these instance variables:

private state: 'idle' | 'running' | 'paused' | 'break' = 'idle';
private remainingSeconds: number = 0;
private intervalId: NodeJS.Timeout | null = null;
private settings: PomodoroSettings = { workMinutes: 25, breakMinutes: 5 };

The key insight: state and remainingSeconds are runtime state (lost on restart), while settings are persisted via the SDK.

When the plugin restarts, you get settings back via onWillAppear, but the timer was reset. This is usually the expected behavior.

Hint 2: Canvas Rendering for Countdown (click to reveal)
import { createCanvas } from 'canvas';

function renderTimer(minutes: number, seconds: number, state: string): string {
  const canvas = createCanvas(144, 144);  // 2x for retina
  const ctx = canvas.getContext('2d');

  // Background color based on state
  const bgColors = {
    idle: '#7f8c8d',
    running: '#27ae60',
    paused: '#f39c12',
    break: '#3498db',
  };
  ctx.fillStyle = bgColors[state] || '#7f8c8d';
  ctx.fillRect(0, 0, 144, 144);

  // Time text
  ctx.fillStyle = 'white';
  ctx.font = 'bold 48px Arial';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  const timeStr = `${minutes}:${seconds.toString().padStart(2, '0')}`;
  ctx.fillText(timeStr, 72, 60);

  // State label
  ctx.font = '20px Arial';
  ctx.fillText(state.toUpperCase(), 72, 100);

  return canvas.toDataURL('image/png');
}

Call this in your render() method and pass to ev.action.setImage().

Hint 3: Settings Persistence Pattern (click to reveal)
// In your action class

interface PomodoroSettings {
  workMinutes: number;
  breakMinutes: number;
  soundEnabled: boolean;
}

// Load settings when action appears
override onWillAppear(ev: WillAppearEvent<PomodoroSettings>): void {
  this.settings = ev.payload.settings ?? {
    workMinutes: 25,
    breakMinutes: 5,
    soundEnabled: true,
  };
  this.remainingSeconds = this.settings.workMinutes * 60;
  this.render();
}

// Handle settings updates from Property Inspector
override onDidReceiveSettings(ev: DidReceiveSettingsEvent<PomodoroSettings>): void {
  this.settings = ev.payload.settings;

  // Only update time if we're idle
  if (this.state === 'idle') {
    this.remainingSeconds = this.settings.workMinutes * 60;
    this.render();
  }
}

In Property Inspector (pi/index.js):

// When user changes setting
document.getElementById('work-minutes').addEventListener('change', (e) => {
  const settings = {
    ...currentSettings,
    workMinutes: parseInt(e.target.value),
  };
  $SD.setSettings(settings);
});
Hint 4: Sound Notification Approach (click to reveal)

Stream Deck plugins run in Node.js, which doesn’t have the browser’s Audio API. Options:

Option 1: play-sound package (recommended)

import player from 'play-sound';

function playNotificationSound(soundFile: string): void {
  const soundPath = path.join(__dirname, '..', 'sounds', soundFile);
  player().play(soundPath, (err) => {
    if (err) console.error('Sound playback failed:', err);
  });
}

Option 2: node-notifier with system sound

import notifier from 'node-notifier';

function playNotificationSound(): void {
  notifier.notify({
    title: 'Pomodoro Timer',
    message: 'Time is up!',
    sound: true,  // Uses system sound
  });
}

Option 3: afplay on Mac (simplest)

import { exec } from 'child_process';

function playNotificationSound(soundPath: string): void {
  exec(`afplay "${soundPath}"`);
}
Hint 5: Long Press Detection (click to reveal)
private keyDownTime: number | null = null;
private readonly LONG_PRESS_MS = 2000;

override onKeyDown(ev: KeyDownEvent): void {
  this.keyDownTime = Date.now();
}

override onKeyUp(ev: KeyUpEvent): void {
  if (this.keyDownTime) {
    const pressDuration = Date.now() - this.keyDownTime;
    this.keyDownTime = null;

    if (pressDuration >= this.LONG_PRESS_MS) {
      // Long press: reset timer
      this.resetTimer();
    } else {
      // Short press: toggle state
      this.handleShortPress();
    }
  }
}

private handleShortPress(): void {
  switch (this.state) {
    case 'idle':
      this.startTimer();
      break;
    case 'running':
      this.pauseTimer();
      break;
    case 'paused':
      this.resumeTimer();
      break;
    case 'break':
      this.pauseTimer();
      break;
  }
}

private resetTimer(): void {
  if (this.intervalId) {
    clearInterval(this.intervalId);
    this.intervalId = null;
  }
  this.state = 'idle';
  this.remainingSeconds = this.settings.workMinutes * 60;
  this.render();
}
Hint 6: Complete Action Class Structure (click to reveal)
import { action, KeyDownEvent, KeyUpEvent, WillAppearEvent,
         WillDisappearEvent, DidReceiveSettingsEvent, SingletonAction } from "@elgato/streamdeck";

interface PomodoroSettings {
  workMinutes: number;
  breakMinutes: number;
  soundEnabled: boolean;
}

type TimerState = 'idle' | 'running' | 'paused' | 'break';

@action({ UUID: "com.example.pomodoro.timer" })
export class PomodoroAction extends SingletonAction<PomodoroSettings> {
  // State
  private state: TimerState = 'idle';
  private remainingSeconds: number = 25 * 60;
  private intervalId: NodeJS.Timeout | null = null;
  private keyDownTime: number | null = null;
  private settings: PomodoroSettings = {
    workMinutes: 25,
    breakMinutes: 5,
    soundEnabled: true,
  };

  // Lifecycle
  override onWillAppear(ev: WillAppearEvent<PomodoroSettings>): void {
    this.settings = ev.payload.settings ?? this.settings;
    this.remainingSeconds = this.settings.workMinutes * 60;
    this.render(ev);
  }

  override onWillDisappear(ev: WillDisappearEvent): void {
    this.cleanup();
  }

  override onKeyDown(ev: KeyDownEvent): void {
    this.keyDownTime = Date.now();
  }

  override onKeyUp(ev: KeyUpEvent): void {
    const duration = Date.now() - (this.keyDownTime ?? Date.now());
    this.keyDownTime = null;

    if (duration >= 2000) {
      this.reset(ev);
    } else {
      this.toggle(ev);
    }
  }

  override onDidReceiveSettings(ev: DidReceiveSettingsEvent<PomodoroSettings>): void {
    this.settings = ev.payload.settings;
    if (this.state === 'idle') {
      this.remainingSeconds = this.settings.workMinutes * 60;
      this.render(ev);
    }
  }

  // Timer logic
  private toggle(ev: any): void {
    switch (this.state) {
      case 'idle': this.start(ev); break;
      case 'running': this.pause(ev); break;
      case 'paused': this.resume(ev); break;
      case 'break': this.pause(ev); break;
    }
  }

  private start(ev: any): void {
    this.state = 'running';
    this.intervalId = setInterval(() => this.tick(ev), 1000);
    this.render(ev);
  }

  private pause(ev: any): void {
    this.state = 'paused';
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
    this.render(ev);
  }

  private resume(ev: any): void {
    this.state = 'running';
    this.intervalId = setInterval(() => this.tick(ev), 1000);
    this.render(ev);
  }

  private reset(ev: any): void {
    this.cleanup();
    this.state = 'idle';
    this.remainingSeconds = this.settings.workMinutes * 60;
    this.render(ev);
  }

  private tick(ev: any): void {
    this.remainingSeconds--;
    if (this.remainingSeconds <= 0) {
      this.onComplete(ev);
    } else {
      this.render(ev);
    }
  }

  private onComplete(ev: any): void {
    this.cleanup();
    if (this.state === 'running') {
      this.state = 'break';
      this.remainingSeconds = this.settings.breakMinutes * 60;
      this.playSound();
      this.intervalId = setInterval(() => this.tick(ev), 1000);
    } else {
      this.state = 'idle';
      this.remainingSeconds = this.settings.workMinutes * 60;
      this.playSound();
    }
    this.render(ev);
  }

  private cleanup(): void {
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
  }

  private render(ev: any): void {
    const mins = Math.floor(this.remainingSeconds / 60);
    const secs = this.remainingSeconds % 60;
    const image = this.generateImage(mins, secs);
    ev.action.setImage(image);
  }

  private generateImage(mins: number, secs: number): string {
    // Canvas rendering code here
    // Return base64 PNG
  }

  private playSound(): void {
    if (this.settings.soundEnabled) {
      // Sound playing code here
    }
  }
}

Extensions & Challenges

Extension 1: Multi-Timer Dashboard

Challenge: Create a dashboard view on Stream Deck that shows multiple timers at once (work timer, break timer, daily count).

Implementation Ideas:

  • Create a separate action that subscribes to timer events
  • Use Stream Deck profiles to create a “dashboard” layout
  • Share state between actions via global settings

Extension 2: Statistics Tracking

Challenge: Track Pomodoro statistics over time - daily sessions, weekly trends, productivity score.

Implementation Ideas:

  • Store completion timestamps in a local SQLite database
  • Create a “Stats” action that shows today’s progress
  • Export data to CSV for analysis
  • Sync with productivity apps via API

Extension 3: Focus Mode Integration

Challenge: When timer is running, trigger Focus/Do Not Disturb modes on macOS/Windows.

Implementation Ideas:

  • Use osascript on Mac to enable Focus
  • Use Windows API for Focus Assist
  • Restore normal mode when timer pauses/completes
  • Option to block specific apps during work periods
// Mac Focus Mode (via Shortcuts automation)
import { exec } from 'child_process';

function enableFocusMode() {
  exec('shortcuts run "Start Focus"');
}

function disableFocusMode() {
  exec('shortcuts run "Stop Focus"');
}

Extension 4: Team Pomodoro

Challenge: Synchronize Pomodoro timers across multiple team members for pair programming or group focus sessions.

Implementation Ideas:

  • Create a simple WebSocket server for sync
  • One person hosts, others join with a code
  • All timers start/stop together
  • Show team member status on the key

Extension 5: Habit Stacking

Challenge: Chain multiple timers together - morning routine (5 min stretch, 2 min meditation, 25 min work).

Implementation Ideas:

  • Define timer sequences in settings
  • Auto-advance through sequence
  • Visual indication of current step
  • Reset restarts sequence from beginning

Real-World Connections

The patterns you learn in this project apply far beyond Stream Deck development:

1. Hardware Display Programming

Similar Domains:

  • Smart watch app development (Apple Watch, Wear OS)
  • IoT device displays (ESP32 with screens)
  • Kiosk applications
  • Digital signage

Transferable Skills:

  • Canvas rendering at small sizes
  • State machine for device modes
  • Efficient update cycles (power management)

2. Real-Time Communication

Similar Domains:

  • WebSocket-based chat applications
  • Live collaboration tools (Figma, Google Docs)
  • Game networking
  • Financial trading interfaces

Transferable Skills:

  • Event-driven architecture
  • Bidirectional message handling
  • State synchronization across clients

3. Timer and Scheduling Systems

Similar Domains:

  • Cron job management UIs
  • Appointment scheduling apps
  • Countdown/event timer websites
  • Task queue monitoring

Transferable Skills:

  • Accurate time tracking (drift prevention)
  • State machine design
  • Progress visualization

4. Settings and Configuration Systems

Similar Domains:

  • Electron app preferences
  • VSCode extension settings
  • Mobile app configuration
  • SaaS user preferences

Transferable Skills:

  • Persistent vs. runtime state
  • Settings UI design
  • Default value handling
  • Migration strategies

5. Plugin Architectures

Similar Domains:

  • VSCode extensions
  • Figma plugins
  • Browser extensions
  • WordPress plugins
  • Jenkins plugins

Transferable Skills:

  • Plugin lifecycle management
  • Host-plugin communication
  • Sandboxed execution
  • API surface design

Books That Will Help

Topic Book Chapter/Section Why It Helps
State machine design The Pragmatic Programmer Topic 26: “How to Balance Resources” Teaches clean state transitions and resource management. The Pomodoro timer’s four states map directly to these patterns.
Event-driven architecture Node.js Design Patterns Ch. 3: “Callbacks and Events” Stream Deck plugins are entirely event-driven. This chapter explains the pattern fundamentals.
Canvas rendering HTML5 Canvas by Fulton & Fulton Ch. 2-4 Drawing dynamic images for the key display. Even though you’re in Node.js, the Canvas API is the same.
TypeScript for settings Effective TypeScript Items 29-37 Type-safe configuration objects. Generic events like WillAppearEvent<T> use these patterns.
Timer precision You Don’t Know JS: Async & Performance Ch. 1 Understanding JavaScript timing guarantees and why setInterval drifts.
Clean code structure Refactoring by Fowler Ch. 10: “Organizing Data” Keeping state management clean as the project grows.
Node.js best practices Node.js Design Patterns Ch. 2: “Asynchronous Control Flow” Your timer callbacks and event handlers all use async patterns.
Desktop integration Electron in Action Ch. 8-9: “Native APIs” Though not Electron, similar patterns for OS integration (sounds, notifications).

Self-Assessment Checklist

Before considering this project complete, verify your understanding:

Conceptual Understanding

  • Can you explain the Stream Deck plugin architecture to a colleague?
  • Can you describe the WebSocket communication flow between plugin and app?
  • Can you list all lifecycle events and when they fire?
  • Can you explain why state machines prevent bugs in this project?
  • Can you describe the difference between settings and runtime state?

Implementation Skills

  • Can you create a Stream Deck plugin from scratch using the CLI?
  • Can you render custom images on keys using Canvas?
  • Can you implement a finite state machine in TypeScript?
  • Can you handle key press events (short press vs long press)?
  • Can you persist and load settings correctly?

State Machine Design

  • Did you implement exactly four states (idle, running, paused, break)?
  • Are all state transitions explicit and documented?
  • Is it impossible to be in two states simultaneously?
  • Does long press reset work from any state?
  • Does the state machine have no dead ends?

Timer Accuracy

  • Does your timer use timestamps instead of counter decrements?
  • Is timer drift less than 1 second over 25 minutes?
  • Does pausing preserve the exact remaining time?
  • Does the timer survive Edge cases (0 duration, 60 min)?

Visual Feedback

  • Are all four states visually distinct?
  • Is the countdown text readable at 72x72 pixels?
  • Does the progress bar accurately reflect completion?
  • Is there clear feedback when state changes?

Persistence

  • Do settings survive plugin restarts?
  • Do settings survive Stream Deck app restarts?
  • Does changing settings update behavior appropriately?
  • Is default behavior sensible when no settings exist?

Error Handling

  • Is there no way to crash the plugin with key presses?
  • Are intervals cleaned up when action is removed?
  • Does sound playback fail gracefully?
  • Are there no zombie processes after hours of use?

Code Quality

  • Is state managed per-instance, not globally?
  • Are there unit tests for the state machine?
  • Is the code organized into logical modules?
  • Could another developer understand your code?

Real-World Readiness

  • Would you use this timer for actual Pomodoro sessions?
  • Is the UX intuitive without reading documentation?
  • Does it work with multiple instances on one deck?
  • Is performance acceptable after 8+ hours of use?

The Core Question You’ve Answered

“How do I make a Stream Deck key update its display dynamically based on internal state, while persisting user preferences across restarts?”

By building this Pomodoro timer, you have mastered:

  1. Plugin Architecture: You understand how Stream Deck plugins run as separate processes, communicate via WebSocket, and integrate with the host application.

  2. Event-Driven Development: You can handle lifecycle events (onWillAppear, onKeyDown) and understand when to perform setup, action, and cleanup.

  3. State Machine Design: You implemented a clean four-state system that prevents impossible configurations and makes debugging straightforward.

  4. Real-Time Rendering: You can generate and push dynamic images to hardware displays at appropriate intervals without performance issues.

  5. Settings Persistence: You understand the difference between configuration (persisted) and runtime state (transient), and handle both correctly.

These patterns transfer directly to:

  • Other Stream Deck plugins (volume controller, scene switcher, API trigger)
  • Smart device programming (watches, IoT displays)
  • Real-time web applications (dashboards, collaboration tools)
  • Any plugin architecture (VSCode, Figma, browsers)

You now have the foundation to build any Stream Deck plugin. The next project will layer additional complexity on these same patterns.


Project Guide Version 1.0 - December 2025