Project 4: Productivity Metrics Tracker

Project 4: Productivity Metrics Tracker

Comprehensive Learning Guide Build a persistent data tracking system with time-based aggregation and mini-chart visualization within the 72x72 pixel constraints of a Stream Deck key


Table of Contents

  1. Learning Objectives
  2. Deep Theoretical Foundation
  3. Complete Project Specification
  4. Real World Outcome
  5. Solution Architecture
  6. Phased Implementation Guide
  7. Testing Strategy
  8. Common Pitfalls & Debugging
  9. Extensions & Challenges
  10. Resources
  11. Self-Assessment Checklist

Metadata

Property Value
Difficulty Intermediate (Level 2)
Time Estimate 1-2 weeks
Main Language TypeScript
Alternative Languages JavaScript, Python, Rust
Knowledge Areas Data Persistence, Time-Based Aggregation, Canvas Visualization, Webhooks
Software/Tools Stream Deck, Notion (optional), Google Sheets (optional)
Main Book Effective TypeScript by Dan Vanderkam
Prerequisites JavaScript/TypeScript, basic data structures, familiarity with Stream Deck SDK basics (Project 1-2)

Learning Objectives

By completing this project, you will master:

  • Plugin Data Directory Usage: Understand streamDeck.plugin.dataPath and how to build a file-based persistence layer that survives app restarts, system reboots, and plugin updates.

  • JSON File Persistence Patterns: Design efficient read/write strategies for JSON storage including atomic writes, file locking considerations, and graceful corruption handling.

  • Time-Based Aggregation: Implement daily and weekly rollups that answer questions like “how many meetings did I have this week?” while handling timezone complexities and midnight boundaries.

  • Date Handling with date-fns: Master reliable date manipulation using startOfDay(), startOfWeek(), format(), isSameDay(), and differenceInDays() to avoid the notorious pitfalls of JavaScript’s Date object.

  • Mini-Chart Canvas Rendering: Draw meaningful bar charts in exactly 72x72 pixels - a challenging constraint that teaches you efficient data visualization design where every pixel counts.

  • CSV Export and File I/O: Implement data export functionality that generates properly formatted CSV files users can open in Excel, Google Sheets, or Numbers.

  • Webhook Integration: Send productivity data to external services (Notion, Google Sheets, custom APIs) using HTTP requests with proper error handling and retry logic.

  • State Management Across Restarts: Design systems where pressing a button increments a counter that persists days, weeks, or months later - understanding the full lifecycle of plugin data.


Deep Theoretical Foundation

Plugin Data Directory: Your Plugin’s Persistent Storage

Every Stream Deck plugin receives a dedicated data directory where it can store files that persist across restarts. This is different from settings (which stores per-action configuration) - the data directory is for plugin-level data like accumulated metrics.

Plugin Directory Structure
==========================

/path/to/plugins/
+-- com.yourname.productivity-tracker/
    +-- manifest.json           # Plugin definition
    +-- plugin.js               # Compiled backend code
    +-- ui/                     # Property Inspector files
    |   +-- property-inspector.html
    |   +-- property-inspector.js
    |
    +-- data/                   # <<< THIS IS dataPath
        +-- metrics.json        # Your persistent data
        +-- export/             # Generated exports
            +-- 2025-01-metrics.csv

Accessing the Data Directory
============================

import streamDeck from '@elgato/streamdeck';

// Get the path to your plugin's data directory
const dataPath = streamDeck.plugin.dataPath;
// Returns: /path/to/plugins/com.yourname.productivity-tracker/data/

// This path is guaranteed to:
// 1. Exist (created by Stream Deck if missing)
// 2. Be writable by your plugin
// 3. Persist across Stream Deck restarts
// 4. Survive plugin updates (within same plugin ID)

Plugin Directory Structure

Critical Understanding: The data directory is NOT backed up to the cloud. If a user reinstalls Stream Deck or moves to a new computer, this data is lost unless you implement your own backup mechanism (like webhook sync).

Data Persistence Lifecycle
==========================

              User installs plugin
                      |
                      v
           +-------------------+
           | Stream Deck App   |
           | creates data/     |
           | directory         |
           +-------------------+
                      |
                      v
           +-------------------+
           | Plugin starts     |
           | Reads metrics.json|
           | (or creates it)   |
           +-------------------+
                      |
        +-------------+-------------+
        |                           |
        v                           v
+---------------+          +---------------+
| User presses  |          | Stream Deck   |
| button        |          | restarts      |
+-------+-------+          +-------+-------+
        |                          |
        v                          |
+---------------+                  |
| Counter++     |                  |
| Write to JSON |                  |
+---------------+                  |
        |                          |
        +-------------+------------+
                      |
                      v
           +-------------------+
           | Plugin restarts   |
           | Reads metrics.json|
           | Data preserved!   |
           +-------------------+

Data Persistence Lifecycle


JSON File Persistence Patterns

Storing data in JSON files seems simple, but production-quality persistence requires careful design to handle edge cases.

Pattern 1: Atomic Writes

The problem: If your plugin crashes mid-write, you can corrupt the JSON file.

The Corruption Problem
======================

Timeline of a crash during write:

T0: metrics.json = {"meetings": 5}     [Valid JSON]
T1: Plugin starts write...
T2: metrics.json = {"meeting           [CRASH! Partial write]
T3: Plugin restarts, tries to parse    [JSON.parse throws!]

Solution: Write to temp file, then rename (atomic operation)

T0: metrics.json = {"meetings": 5}     [Valid JSON]
T1: Write to metrics.tmp = {"meetings": 6}
T2: Rename metrics.tmp -> metrics.json [Atomic on most OS]
T3: Even if crash at T1, metrics.json is still valid

Corruption Problem Timeline

// Safe write implementation
import { writeFileSync, renameSync } from 'fs';
import { join } from 'path';

function safeWriteJSON(filepath: string, data: object): void {
  const tempPath = `${filepath}.tmp`;

  // Write to temporary file
  writeFileSync(tempPath, JSON.stringify(data, null, 2), 'utf-8');

  // Atomic rename
  renameSync(tempPath, filepath);
}

Pattern 2: Read with Fallback

Always assume your JSON file might be corrupted or missing.

import { readFileSync, existsSync } from 'fs';

interface MetricsData {
  days: DailyRecord[];
  lastUpdated: string;
}

const DEFAULT_DATA: MetricsData = {
  days: [],
  lastUpdated: new Date().toISOString(),
};

function loadMetrics(filepath: string): MetricsData {
  // File doesn't exist? Return default
  if (!existsSync(filepath)) {
    return { ...DEFAULT_DATA };
  }

  try {
    const raw = readFileSync(filepath, 'utf-8');
    const parsed = JSON.parse(raw);

    // Validate structure (basic check)
    if (!Array.isArray(parsed.days)) {
      console.warn('Invalid metrics structure, using default');
      return { ...DEFAULT_DATA };
    }

    return parsed;
  } catch (error) {
    console.error('Failed to load metrics:', error);
    // Keep corrupted file for debugging
    if (existsSync(filepath)) {
      renameSync(filepath, `${filepath}.corrupted.${Date.now()}`);
    }
    return { ...DEFAULT_DATA };
  }
}

Pattern 3: Schema Versioning

As your plugin evolves, your data structure changes. Version your schema.

Schema Evolution Example
========================

Version 1 (Initial):
{
  "meetings": 5,
  "tasks": 10
}

Version 2 (Added date tracking):
{
  "schemaVersion": 2,
  "days": [
    { "date": "2025-01-20", "meetings": 5, "tasks": 10 }
  ]
}

Version 3 (Added categories):
{
  "schemaVersion": 3,
  "days": [
    {
      "date": "2025-01-20",
      "metrics": {
        "meetings": { "count": 5, "category": "work" },
        "tasks": { "count": 10, "category": "work" }
      }
    }
  ]
}

Migration Function:
function migrateData(data: any): CurrentSchema {
  if (!data.schemaVersion) {
    // Version 1 -> Current
    return migrateFromV1(data);
  }
  if (data.schemaVersion === 2) {
    return migrateFromV2(data);
  }
  // Already current
  return data;
}

Schema Evolution Example


Time-Based Aggregation: Daily and Weekly Rollups

Tracking “today’s meetings” seems simple until you realize: When does “today” end? What about users in different timezones? What if the user’s computer clock is wrong?

The Timezone Problem
====================

Your plugin runs on the user's computer.
Date.now() returns UTC timestamp.
The user expects "today" to mean their local date.

Example:
- User in New York (EST, UTC-5)
- It's 11 PM on January 20th in New York
- UTC time is 4 AM on January 21st

If you use UTC dates:
  - User presses "Meeting" at 11 PM local time
  - You record it as January 21st (UTC)
  - User sees "Today: 0 meetings" but expected 1

Solution: Always use the user's local timezone

The Timezone Problem

import {
  startOfDay,
  startOfWeek,
  format,
  isSameDay,
  parseISO,
  differenceInDays
} from 'date-fns';

// Get today's date string in LOCAL timezone
function getTodayString(): string {
  return format(new Date(), 'yyyy-MM-dd');
  // Returns "2025-01-20" based on LOCAL time
}

// Get the start of the current week (Monday)
function getWeekStart(): string {
  return format(
    startOfWeek(new Date(), { weekStartsOn: 1 }), // 1 = Monday
    'yyyy-MM-dd'
  );
}

// Check if a date string is "today"
function isToday(dateString: string): boolean {
  const date = parseISO(dateString);
  return isSameDay(date, new Date());
}

// Check if a date string is within the current week
function isThisWeek(dateString: string): boolean {
  const date = parseISO(dateString);
  const today = new Date();
  const weekStart = startOfWeek(today, { weekStartsOn: 1 });
  const daysFromWeekStart = differenceInDays(date, weekStart);
  return daysFromWeekStart >= 0 && daysFromWeekStart < 7;
}

Aggregation Data Flow:

Raw Event Data                    Aggregated Views
==============                    ================

Button Press Events:              Daily View:
+-------------------+             +-------------------+
| 2025-01-20 09:15  | ----+       | 2025-01-20        |
| 2025-01-20 10:30  | ----+-----> | meetings: 3       |
| 2025-01-20 14:45  | ----+       | tasks: 5          |
+-------------------+             +-------------------+
| 2025-01-21 08:00  | ----+
| 2025-01-21 11:15  | ----+-----> Weekly View:
+-------------------+             +-------------------+
                                  | Week of 2025-01-20|
                                  | meetings: 5       |
                                  | tasks: 12         |
                                  | avg/day: 2.4      |
                                  +-------------------+

Storage Strategy: Store daily counts, compute weekly on demand

Raw Event Data to Aggregated Views

Two Aggregation Strategies:

Strategy A: Store Raw Events, Aggregate on Read
==============================================
Pros:
- Maximum flexibility (can recompute any period)
- No data loss
Cons:
- Storage grows without bound
- Aggregation is slow for large datasets

Strategy B: Store Daily Aggregates, Discard Raw Events
=====================================================
Pros:
- Fixed storage size per day
- Fast reads (pre-aggregated)
Cons:
- Cannot get hourly breakdowns
- Cannot recompute if logic changes

Recommended: Strategy B with optional raw event log

{
  "days": [
    { "date": "2025-01-20", "meetings": 3, "tasks": 5 }
  ],
  "recentEvents": [
    // Keep last 7 days of raw events for debugging
    { "timestamp": "2025-01-20T09:15:00Z", "type": "meeting" }
  ]
}

The Midnight Rollover Problem

What happens when the user’s “today” changes while the plugin is running?

Midnight Rollover Scenario
==========================

11:58 PM - User presses "Meeting" button
  - Plugin increments 2025-01-20 meetings count
  - Display shows "Today: 5"

11:59 PM - Nothing happens

12:00 AM - Date changes to 2025-01-21
  - Plugin is unaware!

12:01 AM - User presses "Meeting" button
  - Plugin SHOULD start a new day count
  - But might increment yesterday's count if not checked

Solution 1: Check date on every button press
Solution 2: Set a timer to trigger at midnight
Solution 3: Both (belt and suspenders)

Midnight Rollover Scenario

class DayTracker {
  private currentDay: string;
  private midnightTimer: NodeJS.Timeout | null = null;

  constructor() {
    this.currentDay = getTodayString();
    this.scheduleMidnightRollover();
  }

  private scheduleMidnightRollover(): void {
    // Calculate milliseconds until midnight
    const now = new Date();
    const tomorrow = startOfDay(addDays(now, 1));
    const msUntilMidnight = tomorrow.getTime() - now.getTime();

    // Add 1 second buffer to ensure we're past midnight
    this.midnightTimer = setTimeout(() => {
      this.handleMidnightRollover();
    }, msUntilMidnight + 1000);
  }

  private handleMidnightRollover(): void {
    const newDay = getTodayString();
    if (newDay !== this.currentDay) {
      console.log(`Day rolled over: ${this.currentDay} -> ${newDay}`);
      this.currentDay = newDay;
      // Update all key displays
      this.refreshAllDisplays();
    }
    // Schedule next rollover
    this.scheduleMidnightRollover();
  }

  public incrementMetric(metricName: string): void {
    // Double-check current day (in case timer didn't fire)
    const actualToday = getTodayString();
    if (actualToday !== this.currentDay) {
      this.handleMidnightRollover();
    }

    // Now safe to increment
    this.metrics[this.currentDay][metricName]++;
  }

  public dispose(): void {
    if (this.midnightTimer) {
      clearTimeout(this.midnightTimer);
    }
  }
}

Mini-Chart Canvas Rendering in 72x72 Pixels

The Stream Deck Standard has 72x72 pixel keys. The Stream Deck XL has 96x96 pixels. You must render meaningful data visualizations in this tiny space.

72x72 Pixel Canvas Layout
=========================

+------ 72 pixels wide ------+
|                            |
|   Meetings: 4              |  <- Title (14px, ~12 pixels high)
|                            |  <- 4px padding
|   +-----------------------+|  <- Chart area starts here
|   |                       ||
|   | ▂ ▄ ▆ ▃ ▇ █ ▄        ||  <- 7 bars, ~28 pixels high
|   |                       ||
|   +-----------------------+|
|   M T W T F S S           |  <- Day labels (10px, ~10 pixels)
|                            |
+----------------------------+

Layout Math:
- Total height: 72px
- Title area: 16px (text + padding)
- Chart area: 36px (bars + spacing)
- Labels area: 16px (text + padding)
- Remaining: 4px (margins)

Bar Chart Math:
- 7 bars for 7 days
- Total width for bars: 72 - 8 (margins) = 64px
- Per bar allocation: 64 / 7 ≈ 9px
- Bar width: 6px (with 3px gap)

72x72 Pixel Canvas Layout

import { createCanvas, Canvas } from 'canvas';

interface DayData {
  day: string;  // 'M', 'T', 'W', etc.
  value: number;
}

function renderMiniBarChart(
  title: string,
  todayValue: number,
  weekData: DayData[]
): string {
  const width = 72;
  const height = 72;
  const canvas = createCanvas(width, height);
  const ctx = canvas.getContext('2d');

  // Background
  ctx.fillStyle = '#1a1a2e';
  ctx.fillRect(0, 0, width, height);

  // Title: "Meetings: 4"
  ctx.fillStyle = '#ffffff';
  ctx.font = 'bold 12px Arial';
  ctx.textAlign = 'center';
  ctx.fillText(`${title}: ${todayValue}`, width / 2, 14);

  // Bar chart
  const chartTop = 24;
  const chartHeight = 28;
  const chartBottom = chartTop + chartHeight;

  const barCount = weekData.length;
  const totalBarWidth = width - 8;  // 4px margin each side
  const barWidth = 6;
  const barGap = (totalBarWidth - (barWidth * barCount)) / (barCount - 1);

  // Find max value for scaling
  const maxValue = Math.max(...weekData.map(d => d.value), 1);

  weekData.forEach((day, index) => {
    const x = 4 + index * (barWidth + barGap);
    const barHeight = (day.value / maxValue) * chartHeight;
    const y = chartBottom - barHeight;

    // Bar fill
    ctx.fillStyle = index === getDayIndex() ? '#e94560' : '#4a4a6a';
    ctx.fillRect(x, y, barWidth, barHeight);
  });

  // Day labels
  ctx.fillStyle = '#888888';
  ctx.font = '8px Arial';
  ctx.textAlign = 'center';

  weekData.forEach((day, index) => {
    const x = 4 + index * (barWidth + barGap) + barWidth / 2;
    ctx.fillText(day.day, x, 68);
  });

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

function getDayIndex(): number {
  // 0 = Monday, 6 = Sunday (matching our chart)
  const jsDay = new Date().getDay();  // 0 = Sunday
  return jsDay === 0 ? 6 : jsDay - 1;
}

Visual Design Principles for Tiny Charts:

Good vs Bad Mini-Chart Design
=============================

BAD: Too much detail
+------------------+
| Meetings Today:4 |
| Weekly Avg: 3.2  |
| ▂▄▆▃▇█▄         |
| M T W T F S S   |
| Total: 23       |
+------------------+
Problem: Text is unreadable, too cluttered

GOOD: Focused information
+------------------+
|   Meetings: 4    |
|                  |
|   ▂▄▆▃▇█▄        |
|   M T W T F S S  |
+------------------+
Why it works:
- One clear number (today's count)
- Visual trend (weekly bar chart)
- Minimal text at readable size

Design Rules:
1. Maximum 2 pieces of text information
2. Use visual encoding (bars, colors) over numbers
3. Font size no smaller than 8px
4. High contrast colors (light on dark)
5. Today's bar should be highlighted

CSV Export and File I/O

Users want their data in spreadsheets. CSV export is essential.

CSV Format Requirements
=======================

Basic CSV:
date,meetings,tasks,breaks
2025-01-20,4,12,3
2025-01-21,6,8,4

With proper escaping (for data with commas/quotes):
date,vendor,notes
2025-01-20,"Starbucks","Coffee, with team"
2025-01-21,"Joe's Diner","Lunch with ""John"""

Rules:
1. Fields with commas -> wrap in quotes
2. Fields with quotes -> double the quotes
3. First row = headers
4. One record per line
5. Consistent column order
function escapeCSVField(field: string | number): string {
  const str = String(field);

  // Check if escaping needed
  if (str.includes(',') || str.includes('"') || str.includes('\n')) {
    // Escape quotes by doubling them, wrap in quotes
    return `"${str.replace(/"/g, '""')}"`;
  }

  return str;
}

function generateCSV(data: DailyRecord[]): string {
  const headers = ['date', 'meetings', 'tasks', 'breaks'];
  const lines: string[] = [];

  // Header row
  lines.push(headers.join(','));

  // Data rows
  for (const record of data) {
    const row = [
      escapeCSVField(record.date),
      escapeCSVField(record.meetings),
      escapeCSVField(record.tasks),
      escapeCSVField(record.breaks),
    ];
    lines.push(row.join(','));
  }

  return lines.join('\n');
}

// Export function
async function exportToCSV(dataPath: string, outputDir: string): Promise<string> {
  const metrics = loadMetrics(join(dataPath, 'metrics.json'));
  const csv = generateCSV(metrics.days);

  const filename = `productivity-${format(new Date(), 'yyyy-MM')}.csv`;
  const outputPath = join(outputDir, filename);

  // Ensure output directory exists
  await fs.promises.mkdir(outputDir, { recursive: true });

  // Write CSV
  await fs.promises.writeFile(outputPath, csv, 'utf-8');

  return outputPath;
}

Export Location Options:

Where to Save Exports?
======================

Option A: Plugin data directory
Path: <dataPath>/exports/
Pros: Always accessible, no permission issues
Cons: Hidden from user, hard to find

Option B: User's Documents folder
Path: ~/Documents/StreamDeck/ProductivityExports/
Pros: Easy for user to find
Cons: May require permission, cross-platform path issues

Option C: User-configured location
Path: Settings-defined
Pros: Maximum flexibility
Cons: More complex Property Inspector

Recommended: Option A as default, Option B as "reveal in finder" action

// Cross-platform Documents path
import { homedir } from 'os';
import { join } from 'path';

function getDocumentsPath(): string {
  return join(homedir(), 'Documents');
}

Webhook Integration for External Services

Sending data to Notion, Google Sheets, or custom APIs extends your plugin’s usefulness.

Webhook Architecture
====================

Stream Deck Plugin              External Service
==================              ================

  [Button Press]
        |
        v
  [Increment Counter]
  [Save to JSON]
        |
        v
  [Check webhook config]
        |
        +---(webhook enabled?)---> [Build HTTP Request]
                                          |
                                          v
                                   [POST to webhook URL]
                                          |
                                          v
                                   [Notion/Sheets/Custom]
                                          |
                                          v
                                   [Handle Response]
                                          |
        <------(success/failure)----------+
        |
        v
  [Update key with status]

Failure Handling:
- Network down? Queue for retry
- Rate limited? Exponential backoff
- Auth failed? Notify user

Webhook Architecture

import axios from 'axios';

interface WebhookConfig {
  enabled: boolean;
  url: string;
  method: 'POST' | 'PUT';
  headers?: Record<string, string>;
  retryCount: number;
  retryDelayMs: number;
}

interface MetricEvent {
  type: string;
  value: number;
  date: string;
  timestamp: string;
}

class WebhookSender {
  private queue: MetricEvent[] = [];
  private isProcessing = false;

  constructor(private config: WebhookConfig) {}

  async send(event: MetricEvent): Promise<void> {
    if (!this.config.enabled) return;

    this.queue.push(event);
    this.processQueue();
  }

  private async processQueue(): Promise<void> {
    if (this.isProcessing || this.queue.length === 0) return;

    this.isProcessing = true;

    while (this.queue.length > 0) {
      const event = this.queue[0];

      try {
        await this.sendWithRetry(event);
        this.queue.shift();  // Remove on success
      } catch (error) {
        console.error('Webhook failed after retries:', error);
        // Keep in queue for next attempt or notify user
        break;
      }
    }

    this.isProcessing = false;
  }

  private async sendWithRetry(event: MetricEvent): Promise<void> {
    let lastError: Error | null = null;

    for (let attempt = 0; attempt < this.config.retryCount; attempt++) {
      try {
        await axios({
          method: this.config.method,
          url: this.config.url,
          headers: {
            'Content-Type': 'application/json',
            ...this.config.headers,
          },
          data: event,
          timeout: 10000,
        });
        return;  // Success
      } catch (error) {
        lastError = error as Error;

        // Exponential backoff
        const delay = this.config.retryDelayMs * Math.pow(2, attempt);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }

    throw lastError;
  }
}

Common Webhook Targets:

Notion Integration
==================
URL: https://api.notion.com/v1/pages
Headers:
  Authorization: Bearer <integration_token>
  Notion-Version: 2022-06-28

Body:
{
  "parent": { "database_id": "<database_id>" },
  "properties": {
    "Date": { "date": { "start": "2025-01-20" } },
    "Meetings": { "number": 5 },
    "Tasks": { "number": 12 }
  }
}

Google Sheets (via Apps Script)
==============================
URL: https://script.google.com/macros/s/<deployment_id>/exec
Method: POST
Body: { "date": "2025-01-20", "meetings": 5, "tasks": 12 }

The Apps Script receives POST data and appends to sheet.

Custom Webhook (Zapier/Make/n8n)
================================
URL: Your webhook URL from the automation platform
Method: POST
Body: Any JSON structure you define

Concepts You Must Understand First

Before diving into implementation, ensure you understand these foundations:

  1. Plugin Data Persistence - Stream Deck provides a data directory for each plugin. Where is it? How do you read/write files there?
  2. Time-Based Aggregation - How do you track “today’s count” vs “this week’s counts”? What happens at midnight?
    • Book: Effective TypeScript by Vanderkam - Item 28: Valid state representation
  3. Mini-Chart Rendering - Drawing a 7-bar chart in 72 pixels requires careful design. Each bar is approximately 8 pixels wide.
    • Book: HTML5 Canvas by Fulton - Ch. 2: Drawing basics
  4. File I/O in Node.js - Synchronous vs asynchronous file operations, JSON serialization.
    • Book: Node.js Design Patterns by Casciaro - Ch. 5: Async patterns

The Core Question You’re Answering

“How do I build persistent data storage with time-based aggregation and data visualization within the constraints of a Stream Deck plugin?”

This project teaches you that Stream Deck plugins can maintain meaningful state across days and weeks. You’re not just responding to button presses - you’re building a tiny database that accumulates data over time and presents it visually.


Complete Project Specification

Functional Requirements

Core Features (Must Have):

Feature Description Priority
Counter actions “Meeting”, “Task”, “Break” buttons that increment on press P0
Persistent storage Counts survive Stream Deck and computer restarts P0
Daily tracking “Today’s meetings” shown on key P0
Weekly bar chart Mini visualization of the week’s activity P0
Midnight rollover “Today” resets at midnight automatically P0
CSV export Long-press exports data to Documents folder P1
Webhook integration Send data to external services P2
Custom metrics User-defined metric names via Property Inspector P2

Data Schema Requirements:

// Stored in metrics.json
interface MetricsStore {
  schemaVersion: number;
  days: DailyRecord[];
  config: MetricsConfig;
}

interface DailyRecord {
  date: string;           // "2025-01-20" (local date)
  metrics: Record<string, number>;  // { "meetings": 4, "tasks": 12 }
}

interface MetricsConfig {
  metrics: MetricDefinition[];
  webhook?: WebhookConfig;
  exportPath?: string;
}

interface MetricDefinition {
  id: string;             // "meetings"
  name: string;           // "Meetings"
  icon?: string;          // Optional icon for key
  color?: string;         // Bar chart color
}

Action Types:

Counter Action
==============
UUID: com.yourname.productivity.counter
Behavior:
- Single press: Increment today's count
- Long press: Export CSV
- Display: Title + today's count + weekly bar chart

Reset Action
============
UUID: com.yourname.productivity.reset
Behavior:
- Single press: Shows confirmation
- Double press: Resets today's count
- Display: "Reset" icon

Summary Action
==============
UUID: com.yourname.productivity.summary
Behavior:
- Single press: Cycle through metrics (meetings -> tasks -> breaks)
- Display: Weekly total with bar chart

Non-Functional Requirements

Requirement Target Rationale
Write latency < 50ms User shouldn’t notice delay on button press
Storage size < 1MB/year Keep data footprint reasonable
Startup time < 500ms Don’t delay Stream Deck app launch
Memory usage < 50MB Stream Deck runs multiple plugins

Real World Outcome

What Your Stream Deck Key Looks Like

Counter Key Display
===================

+----------------------+
|                      |
|    Meetings: 4       |    <- Bold title with today's count
|                      |
|    +-----------+     |
|    |           |     |
|    | ▂ ▄ ▆ ▃ ▇ █ ▄ |     |    <- Weekly bar chart
|    |           |     |
|    +-----------+     |
|    M T W T F S S     |    <- Day labels
|                      |
+----------------------+

The mini bar chart shows this week's meeting counts:
- Monday: 2 meetings (▂)
- Tuesday: 4 meetings (▄)
- Wednesday: 6 meetings (▆)
- Thursday: 3 meetings (▃)
- Friday: 7 meetings (▇)
- Saturday: 8 meetings (█) <- Today
- Sunday: 4 meetings (▄)

Color Coding:
- Today's bar: Highlighted (red/orange)
- Other days: Muted (gray/blue)
- No data: Empty bar

Counter Key Display

Multiple Metric Keys

Full Productivity Dashboard (3 Keys)
====================================

+------------------+  +------------------+  +------------------+
|   Meetings: 4    |  |    Tasks: 12     |  |   Breaks: 3      |
|                  |  |                  |  |                  |
|   ▂▄▆▃▇█▄        |  |   ▃▅▇▄▆▇▅        |  |   ▂▂▃▂▄▂▃        |
|   M T W T F S S  |  |   M T W T F S S  |  |   M T W T F S S  |
+------------------+  +------------------+  +------------------+

Press each key to increment:
- "Meetings" key: Track calls, meetings, standups
- "Tasks" key: Track completed to-dos
- "Breaks" key: Track coffee breaks, walks, lunch

CSV Export Output

Export triggered by long-press
==============================

File saved: ~/Documents/StreamDeck/productivity_2025-01.csv

+------------+----------+-------+--------+
| date       | meetings | tasks | breaks |
+------------+----------+-------+--------+
| 2025-01-01 | 0        | 0     | 0      |
| 2025-01-02 | 3        | 8     | 2      |
| 2025-01-03 | 5        | 12    | 3      |
| ...        | ...      | ...   | ...    |
| 2025-01-20 | 4        | 12    | 3      |
+------------+----------+-------+--------+

Export includes all days from the current month.
Empty days (weekends) are included with zero counts.

Webhook Notification

Webhook payload sent to Notion/Sheets
=====================================

POST https://api.notion.com/v1/pages

{
  "timestamp": "2025-01-20T15:30:00-05:00",
  "event": "metric_increment",
  "metric": "meetings",
  "newValue": 5,
  "dailyTotals": {
    "meetings": 5,
    "tasks": 12,
    "breaks": 3
  },
  "weeklyTotals": {
    "meetings": 23,
    "tasks": 67,
    "breaks": 15
  }
}

Key flashes green on successful webhook send.
Key flashes red if webhook fails (with retry queued).

Solution Architecture

System Architecture Diagram

+-----------------------------------------------------------------------+
|                     PRODUCTIVITY METRICS TRACKER                       |
+-----------------------------------------------------------------------+
|                                                                        |
|  +------------------+     +------------------+     +----------------+  |
|  |    Actions       |     |    Services      |     |    Storage     |  |
|  |------------------|     |------------------|     |----------------|  |
|  | CounterAction    |---->| MetricsService   |---->| JSONStore      |  |
|  | ResetAction      |     | ChartRenderer    |     | CSVExporter    |  |
|  | SummaryAction    |     | WebhookSender    |     |                |  |
|  +------------------+     +------------------+     +----------------+  |
|           |                       |                       |            |
|           |                       v                       |            |
|           |               +------------------+            |            |
|           |               |   DayTracker     |            |            |
|           |               |------------------|            |            |
|           +-------------->| - currentDay     |<-----------+            |
|                           | - midnightTimer  |                         |
|                           | - rolloverLogic  |                         |
|                           +------------------+                         |
|                                                                        |
+-----------------------------------------------------------------------+
                                     |
                    +----------------+----------------+
                    |                                 |
                    v                                 v
           +----------------+               +------------------+
           | Stream Deck    |               | External APIs    |
           | (Display Keys) |               | (Notion, Sheets) |
           +----------------+               +------------------+

System Architecture

Module Breakdown

src/
+-- plugin.ts                 # Entry point, registers actions
+-- actions/                  # Action handlers
|   +-- counter-action.ts     # Main counter button logic
|   +-- reset-action.ts       # Reset functionality
|   +-- summary-action.ts     # Weekly summary display
+-- services/                 # Business logic
|   +-- metrics-service.ts    # Core metrics operations
|   +-- day-tracker.ts        # Midnight rollover handling
|   +-- chart-renderer.ts     # Canvas-based mini charts
|   +-- webhook-sender.ts     # External API integration
+-- storage/                  # Data persistence
|   +-- json-store.ts         # Safe JSON read/write
|   +-- csv-exporter.ts       # CSV generation and export
+-- types/                    # TypeScript definitions
|   +-- metrics.ts            # Metric data types
|   +-- config.ts             # Configuration types
+-- utils/                    # Utility functions
    +-- date.ts               # Date manipulation helpers
    +-- logger.ts             # Structured logging

Data Flow Diagram

Button Press                    Processing                       Output
============                    ==========                        ======

User presses
"Meetings" key
      |
      v
+-------------+
| Stream Deck |
| sends       |
| keyDown     |
+------+------+
       |
       v
+---------------+     +----------------+
| CounterAction |---->| MetricsService |
| onKeyDown()   |     | increment()    |
+---------------+     +-------+--------+
                              |
          +-------------------+-------------------+
          |                   |                   |
          v                   v                   v
   +------------+      +-------------+     +-------------+
   | DayTracker |      | JSONStore   |     | WebhookSend |
   | ensureDay()|      | write()     |     | send()      |
   +------------+      +-------------+     +-------------+
          |                   |                   |
          +-------------------+                   |
                              |                   |
                              v                   |
                       +--------------+           |
                       | ChartRenderer|           |
                       | render()     |           |
                       +------+-------+           |
                              |                   |
                              v                   |
                       +--------------+           |
                       | setImage()   |<----------+
                       | on Stream    |
                       | Deck key     |
                       +--------------+

Data Flow Diagram

State Management

State Architecture
==================

Global State (MetricsService singleton):
+--------------------------------------------+
| metricsData: MetricsStore                  |
|   - days: DailyRecord[]                    |
|   - config: MetricsConfig                  |
| currentDay: string                         |
| isDirty: boolean                           |
| saveTimer: NodeJS.Timeout | null           |
+--------------------------------------------+

Per-Action State (in CounterAction):
+--------------------------------------------+
| context: string (unique action instance ID)|
| metricId: string (which metric to track)   |
| settings: { webhookEnabled, exportPath }   |
+--------------------------------------------+

State Update Flow:
1. Action receives keyDown
2. Call metricsService.increment(metricId)
3. MetricsService updates metricsData
4. MetricsService marks isDirty = true
5. Debounced save (100ms delay)
6. MetricsService calls JSONStore.write()
7. Action calls chartRenderer.render()
8. Action calls setImage() with rendered chart

Phased Implementation Guide

Phase 1: Basic Persistence (Day 1-2)

Goal: Counter that persists across Stream Deck restarts.

Milestone: Button press increments a number that survives restart.

Tasks:

  1. Project Setup
    # Create new plugin
    streamdeck create
    # Select: TypeScript, Counter action template
    
    cd your-plugin
    npm install date-fns canvas
    npm install -D @types/node
    
  2. Create JSON Store (src/storage/json-store.ts)
    • Implement loadMetrics() with corruption handling
    • Implement saveMetrics() with atomic write
    • Create default metrics structure
  3. Create Metrics Service (src/services/metrics-service.ts)
    • Initialize on plugin start
    • Implement increment(metricId: string)
    • Simple save after each increment
  4. Wire Up Counter Action
    • On keyDown, call metricsService.increment()
    • Update key title with new count
    • Test restart persistence

Success Criteria: Pressing button shows incrementing number, restart preserves count.


Phase 2: Daily Aggregation (Day 2-3)

Goal: Track “today’s” count with midnight rollover.

Milestone: Count resets at midnight, historical data preserved.

Tasks:

  1. Add Date Utilities (src/utils/date.ts)
    • Install and configure date-fns
    • Create getTodayString(), isToday() helpers
    • Create getWeekDays() for chart data
  2. Create Day Tracker (src/services/day-tracker.ts)
    • Track current day string
    • Set timer for midnight rollover
    • Emit event on day change
  3. Update Data Structure
    • Change from single counter to daily records array
    • Implement migration from old format
    • Add date to each record
  4. Update Counter Action
    • Show “Meetings: X” where X is today’s count
    • Handle day change during runtime
    • Verify week’s worth of data accumulates

Success Criteria: Button shows today’s count, yesterday’s count preserved separately.


Phase 3: Mini Bar Chart Visualization (Day 3-5)

Goal: Display weekly trend as bar chart on key.

Milestone: Key shows 7-bar chart representing this week’s data.

Tasks:

  1. Install Canvas (npm install canvas)
    • May need system dependencies (see canvas npm docs)
    • Test basic canvas creation
  2. Create Chart Renderer (src/services/chart-renderer.ts)
    • Create 72x72 canvas
    • Draw title text
    • Draw 7 bars with proper scaling
    • Draw day labels
    • Return base64 data URL
  3. Integrate with Action
    • Call renderer on each update
    • Use setImage() to update key
    • Highlight today’s bar differently
  4. Polish Visual Design
    • Add colors for today vs other days
    • Handle zero-data days
    • Test on different Stream Deck models (72x72 vs 96x96)

Success Criteria: Key displays mini bar chart that updates on each press.


Phase 4: CSV Export (Day 5-6)

Goal: Long-press exports data to CSV.

Milestone: CSV file appears in Documents folder.

Tasks:

  1. Implement Long Press Detection
    • Use onKeyDown and onKeyUp timing
    • Threshold: 1.5+ seconds = long press
    • Visual feedback during hold
  2. Create CSV Exporter (src/storage/csv-exporter.ts)
    • Generate proper CSV with escaping
    • Include all days in current month
    • Handle special characters in metric names
  3. File Output
    • Create export directory if needed
    • Generate dated filename
    • Write file with error handling
  4. User Feedback
    • Flash key on successful export
    • Show error message on failure
    • Add export path to Property Inspector

Success Criteria: Long-press creates valid CSV file openable in Excel.


Phase 5: Webhook Integration (Day 6-8)

Goal: Send metrics to external services.

Milestone: Each button press triggers webhook to Notion/Sheets.

Tasks:

  1. Create Webhook Service (src/services/webhook-sender.ts)
    • Configure from Property Inspector settings
    • Queue for retry on failure
    • Exponential backoff
  2. Add Property Inspector Fields
    • Webhook URL input
    • Authorization header input
    • Enable/disable toggle
    • Test connection button
  3. Integrate with Counter Action
    • Send webhook after increment
    • Don’t block button response
    • Show webhook status on key (small icon)
  4. Error Handling
    • Visual indicator of webhook health
    • Retry failed sends
    • Log errors for debugging

Success Criteria: Button press adds row to Notion database or Google Sheet.


Phase 6: Polish and Multi-Metric (Day 8-10)

Goal: Support multiple metrics with configuration.

Milestone: User can create custom metrics via Property Inspector.

Tasks:

  1. Multi-Metric Support
    • Property Inspector dropdown to select metric
    • Create new metrics via settings
    • Each action instance tracks different metric
  2. Summary Action
    • Shows all metrics for the week
    • Cycles through metrics on press
    • Pie chart or list view
  3. Reset Functionality
    • Confirmation dialog (double-press)
    • Reset single metric vs all metrics
    • Preserve historical data
  4. Final Polish
    • Performance optimization (debounced saves)
    • Memory cleanup on dispose
    • Documentation and README

Success Criteria: Professional plugin with custom metrics, export, and webhook.


Questions to Guide Your Design

Think through these before coding:

  1. Data Structure: Where does plugin data persist across restarts? (Hint: streamDeck.plugin.dataPath)

  2. Midnight Handling: How do you handle midnight rollover? (Timer? Check on each press? Both?)

  3. Visualization: How do you render a readable bar chart in 72 pixels? (Max 7-8 bars with spacing)

  4. JSON Schema: What JSON structure stores daily counts efficiently?

  5. Export Format: What format should the CSV export use? What columns?

  6. Timezone: How do you ensure “today” matches the user’s local date?

  7. Concurrent Access: What happens if the user rapidly presses the button multiple times?


Thinking Exercise

Design the data structure:

// Option A: Array of daily records
{
  "days": [
    { "date": "2025-01-20", "meetings": 4, "tasks": 12, "breaks": 3 },
    { "date": "2025-01-21", "meetings": 6, "tasks": 8, "breaks": 4 }
  ]
}

// Option B: Nested by date
{
  "2025-01-20": { "meetings": 4, "tasks": 12, "breaks": 3 },
  "2025-01-21": { "meetings": 6, "tasks": 8, "breaks": 4 }
}

// Questions to consider:
// - Which is better for weekly aggregation? (Need to iterate 7 specific dates)
// - Which is better for CSV export? (Need all days in order)
// - Which is easier to add new metrics to?
// - Which handles missing days better?
// - Which is easier to query for "get last 30 days"?
Analysis and Recommendation

Option A (Array) Advantages:

  • Natural ordering for iteration
  • Easy to filter by date range with filter()
  • Simple to add to: days.push(newRecord)
  • Missing days are simply absent
  • CSV export: direct mapping to rows

Option B (Object by Date) Advantages:

  • O(1) lookup for specific date: data["2025-01-20"]
  • No duplicate date checking needed
  • Slightly less storage for sparse data

Recommendation: Option A (Array)

Why:

  1. Weekly aggregation needs to iterate through days anyway - O(1) lookup doesn’t help
  2. CSV export naturally maps to array of records
  3. Easier to implement “last N days” queries
  4. More flexible for adding metadata per record

Enhanced Structure:

{
  "schemaVersion": 1,
  "days": [
    {
      "date": "2025-01-20",
      "metrics": { "meetings": 4, "tasks": 12 }
    }
  ],
  "metadata": {
    "created": "2025-01-15T10:00:00Z",
    "lastModified": "2025-01-20T15:30:00Z"
  }
}

The Interview Questions They’ll Ask

When discussing this project in interviews, be prepared for:

  1. “How do you persist data in a Stream Deck plugin that survives app restarts?”
    • Discuss dataPath, JSON storage, atomic writes
    • Mention corruption handling and schema versioning
  2. “Explain your approach to time-based data aggregation.”
    • Discuss daily records, timezone handling
    • Explain midnight rollover logic
  3. “How would you optimize storage if the user tracks data for years?”
    • Discuss rolling window (keep last 90 days)
    • Mention monthly archives, compression
    • Consider SQLite for large datasets
  4. “How do you handle timezone issues with date-based aggregation?”
    • Always use local time for “today”
    • Store dates as strings, not timestamps
    • Discuss edge cases (DST, travel)
  5. “How did you render meaningful visualization in 72 pixels?”
    • Discuss visual hierarchy decisions
    • Explain bar chart scaling logic
    • Mention color choices for accessibility

Hints in Layers

Use these progressive hints if you get stuck:

Hint 1: Basic Persistence

Use fs.readFileSync and fs.writeFileSync with streamDeck.plugin.dataPath for simple persistence. The data path is available after the plugin initializes.

import streamDeck from '@elgato/streamdeck';
import { join } from 'path';
import { readFileSync, writeFileSync } from 'fs';

const dataFile = join(streamDeck.plugin.dataPath, 'metrics.json');
Hint 2: Date Handling

Use the date-fns library for reliable date manipulation. Key functions:

import {
  format,           // format(date, 'yyyy-MM-dd')
  startOfDay,       // Get midnight of a date
  startOfWeek,      // Get Monday of current week
  isSameDay,        // Compare two dates
  parseISO,         // Parse "2025-01-20" string
  addDays,          // Add/subtract days
  differenceInDays  // Days between dates
} from 'date-fns';
Hint 3: Bar Chart Rendering

For the bar chart, calculate the maximum value first, then scale all bars relative to the max:

const values = weekData.map(d => d.value);
const maxValue = Math.max(...values, 1);  // Minimum of 1 to avoid division by zero

const barHeight = (value / maxValue) * maxChartHeight;
Hint 4: CSV Generation

For CSV, use a library like Papa Parse for proper escaping, or implement simple string concatenation with proper quote handling:

function toCSV(data: Record<string, any>[]): string {
  const headers = Object.keys(data[0]).join(',');
  const rows = data.map(row =>
    Object.values(row).map(v =>
      typeof v === 'string' && v.includes(',') ? `"${v}"` : v
    ).join(',')
  );
  return [headers, ...rows].join('\n');
}
Hint 5: Midnight Rollover

Calculate milliseconds until midnight and set a timeout:

function getMsUntilMidnight(): number {
  const now = new Date();
  const tomorrow = startOfDay(addDays(now, 1));
  return tomorrow.getTime() - now.getTime();
}

function scheduleMidnight() {
  setTimeout(() => {
    handleDayChange();
    scheduleMidnight();  // Reschedule
  }, getMsUntilMidnight() + 1000);  // +1s buffer
}
Hint 6: Long Press Detection

Track keyDown timestamp and compare on keyUp:

private keyDownTime: number = 0;
private readonly LONG_PRESS_MS = 1500;

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

onKeyUp() {
  const duration = Date.now() - this.keyDownTime;
  if (duration >= this.LONG_PRESS_MS) {
    this.handleLongPress();
  } else {
    this.handleShortPress();
  }
}

Testing Strategy

Unit Tests: Date Logic

Test that date utilities handle edge cases correctly:

// tests/unit/date.test.ts
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { getTodayString, isThisWeek, getWeekDays } from '../../src/utils/date';

describe('getTodayString', () => {
  beforeEach(() => {
    // Mock system date
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('returns correct format', () => {
    vi.setSystemTime(new Date('2025-01-20T15:30:00'));
    expect(getTodayString()).toBe('2025-01-20');
  });

  it('handles midnight correctly', () => {
    vi.setSystemTime(new Date('2025-01-20T00:00:01'));
    expect(getTodayString()).toBe('2025-01-20');
  });

  it('handles 11:59 PM correctly', () => {
    vi.setSystemTime(new Date('2025-01-20T23:59:59'));
    expect(getTodayString()).toBe('2025-01-20');
  });
});

describe('isThisWeek', () => {
  it('correctly identifies days in current week', () => {
    vi.setSystemTime(new Date('2025-01-22T12:00:00')); // Wednesday

    expect(isThisWeek('2025-01-20')).toBe(true);  // Monday
    expect(isThisWeek('2025-01-26')).toBe(true);  // Sunday
    expect(isThisWeek('2025-01-19')).toBe(false); // Previous Sunday
    expect(isThisWeek('2025-01-27')).toBe(false); // Next Monday
  });
});

Unit Tests: JSON Store

Test file operations with corruption scenarios:

// tests/unit/json-store.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { loadMetrics, saveMetrics, DEFAULT_METRICS } from '../../src/storage/json-store';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';

describe('JSONStore', () => {
  let tempDir: string;

  beforeEach(() => {
    tempDir = mkdtempSync(join(tmpdir(), 'metrics-test-'));
  });

  afterEach(() => {
    rmSync(tempDir, { recursive: true });
  });

  it('returns default when file does not exist', () => {
    const result = loadMetrics(join(tempDir, 'nonexistent.json'));
    expect(result).toEqual(DEFAULT_METRICS);
  });

  it('returns default when file is corrupted', () => {
    const filepath = join(tempDir, 'corrupted.json');
    writeFileSync(filepath, 'not valid json {{{', 'utf-8');

    const result = loadMetrics(filepath);
    expect(result).toEqual(DEFAULT_METRICS);
  });

  it('preserves data through save/load cycle', () => {
    const filepath = join(tempDir, 'metrics.json');
    const data = {
      schemaVersion: 1,
      days: [{ date: '2025-01-20', metrics: { meetings: 5 } }],
    };

    saveMetrics(filepath, data);
    const loaded = loadMetrics(filepath);

    expect(loaded).toEqual(data);
  });
});

Integration Tests: Metrics Service

Test the full increment flow:

// tests/integration/metrics-service.test.ts
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MetricsService } from '../../src/services/metrics-service';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';

describe('MetricsService', () => {
  let tempDir: string;
  let service: MetricsService;

  beforeEach(() => {
    tempDir = mkdtempSync(join(tmpdir(), 'metrics-svc-'));
    service = new MetricsService(tempDir);
  });

  afterEach(() => {
    service.dispose();
    rmSync(tempDir, { recursive: true });
  });

  it('increments metric correctly', () => {
    service.increment('meetings');
    expect(service.getTodayValue('meetings')).toBe(1);

    service.increment('meetings');
    expect(service.getTodayValue('meetings')).toBe(2);
  });

  it('tracks multiple metrics independently', () => {
    service.increment('meetings');
    service.increment('meetings');
    service.increment('tasks');

    expect(service.getTodayValue('meetings')).toBe(2);
    expect(service.getTodayValue('tasks')).toBe(1);
  });

  it('returns week data for chart', () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2025-01-22T12:00:00')); // Wednesday

    service.increment('meetings');  // Today

    const weekData = service.getWeekData('meetings');

    expect(weekData).toHaveLength(7);
    expect(weekData[2].value).toBe(1);  // Wednesday
    expect(weekData[0].value).toBe(0);  // Monday

    vi.useRealTimers();
  });
});

Visual Testing: Chart Renderer

For visual components, snapshot testing or manual inspection:

// tests/integration/chart-renderer.test.ts
import { describe, it, expect } from 'vitest';
import { renderMiniBarChart } from '../../src/services/chart-renderer';
import { writeFileSync } from 'fs';
import { join } from 'path';

describe('ChartRenderer', () => {
  it('produces valid data URL', () => {
    const weekData = [
      { day: 'M', value: 2 },
      { day: 'T', value: 4 },
      { day: 'W', value: 6 },
      { day: 'T', value: 3 },
      { day: 'F', value: 7 },
      { day: 'S', value: 8 },
      { day: 'S', value: 4 },
    ];

    const result = renderMiniBarChart('Meetings', 8, weekData);

    expect(result).toMatch(/^data:image\/png;base64,/);
  });

  it('handles zero values gracefully', () => {
    const weekData = Array(7).fill({ day: 'X', value: 0 });

    const result = renderMiniBarChart('Empty', 0, weekData);

    expect(result).toMatch(/^data:image\/png;base64,/);
  });

  // For manual visual inspection during development:
  it.skip('saves chart for visual inspection', () => {
    const weekData = [
      { day: 'M', value: 2 },
      { day: 'T', value: 4 },
      { day: 'W', value: 6 },
      { day: 'T', value: 3 },
      { day: 'F', value: 7 },
      { day: 'S', value: 8 },
      { day: 'S', value: 4 },
    ];

    const dataUrl = renderMiniBarChart('Meetings', 8, weekData);
    const base64 = dataUrl.replace('data:image/png;base64,', '');
    const buffer = Buffer.from(base64, 'base64');

    writeFileSync(join(__dirname, 'chart-output.png'), buffer);
    console.log('Saved chart to tests/integration/chart-output.png');
  });
});

Common Pitfalls & Debugging

Pitfall 1: Timezone Confusion

Symptom: “Today’s” count includes yesterday’s data, or data appears on wrong day.

Bad:

// Using UTC - wrong for local "today"
const today = new Date().toISOString().split('T')[0];
// At 11 PM EST, this returns tomorrow's date (UTC)

Good:

// Using local timezone
import { format } from 'date-fns';
const today = format(new Date(), 'yyyy-MM-dd');
// Always returns the user's local date

Debug: Log both UTC and local dates to see the difference:

console.log('UTC:', new Date().toISOString());
console.log('Local:', format(new Date(), "yyyy-MM-dd'T'HH:mm:ssxxx"));

Pitfall 2: File Corruption on Crash

Symptom: After crash or force quit, metrics.json is empty or invalid.

Bad:

// Direct write - can corrupt on crash
writeFileSync(filepath, JSON.stringify(data));

Good:

// Atomic write via temp file
const tempPath = `${filepath}.tmp`;
writeFileSync(tempPath, JSON.stringify(data, null, 2));
renameSync(tempPath, filepath);

Debug: Check for .tmp or .corrupted files in data directory.


Pitfall 3: Midnight Timer Drift

Symptom: Day doesn’t roll over exactly at midnight, can be minutes late.

Bad:

// Calculate once at startup
const msToMidnight = /* ... */;
setTimeout(handleMidnight, msToMidnight);
// Timer can drift due to system sleep, etc.

Good:

// Double-check date on every operation
function increment(metric: string) {
  const actualToday = getTodayString();
  if (actualToday !== this.currentDay) {
    this.handleDayChange();
  }
  // Then increment
}

Pitfall 4: Canvas Not Installed

Symptom: Error: Canvas binary not found or similar.

Fix: The canvas npm package requires native dependencies:

# macOS
brew install pkg-config cairo pango libpng jpeg giflib librsvg

# Ubuntu/Debian
sudo apt-get install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev

# Windows
# Usually works out of the box, but may need:
npm install --global windows-build-tools

Pitfall 5: Webhook Blocking Button Response

Symptom: Button press feels laggy when webhook is enabled.

Bad:

async onKeyDown() {
  await metricsService.increment('meetings');
  await webhookSender.send(data);  // Waits for HTTP response!
  this.updateDisplay();
}

Good:

async onKeyDown() {
  metricsService.increment('meetings');
  this.updateDisplay();

  // Fire and forget - don't await
  webhookSender.send(data).catch(err => {
    console.error('Webhook failed:', err);
  });
}

Pitfall 6: Memory Leak on Timer

Symptom: Plugin memory usage grows over time.

Bad:

// Setting new timer without clearing old one
scheduleMidnight() {
  setTimeout(() => {
    this.handleDayChange();
    this.scheduleMidnight();
  }, msToMidnight);
}

Good:

private midnightTimer: NodeJS.Timeout | null = null;

scheduleMidnight() {
  // Clear existing timer
  if (this.midnightTimer) {
    clearTimeout(this.midnightTimer);
  }

  this.midnightTimer = setTimeout(() => {
    this.handleDayChange();
    this.scheduleMidnight();
  }, msToMidnight);
}

dispose() {
  if (this.midnightTimer) {
    clearTimeout(this.midnightTimer);
    this.midnightTimer = null;
  }
}

Pitfall 7: Rapid Button Presses Cause Race Condition

Symptom: Pressing button rapidly sometimes loses counts.

Bad:

async increment(metric: string) {
  const data = await loadMetrics();  // Async read
  data.metrics[metric]++;
  await saveMetrics(data);           // Async write
  // Two rapid presses: read1, read2, write1, write2 = lost increment!
}

Good:

// Keep data in memory, debounce saves
private metricsData: MetricsStore;
private saveTimer: NodeJS.Timeout | null = null;

increment(metric: string) {
  // Synchronous in-memory update
  this.metricsData.metrics[metric]++;

  // Debounced save
  if (this.saveTimer) clearTimeout(this.saveTimer);
  this.saveTimer = setTimeout(() => {
    this.persistToFile();
  }, 100);
}

Extensions & Challenges

Extension 1: Notion Integration

Connect your metrics directly to a Notion database.

Challenge: When you press a button, automatically add a row to a Notion database with the metric type, count, and timestamp.

Implementation Ideas:

  • Use Notion’s official API
  • Create a database with Date, Metric Type, and Count properties
  • Store Notion integration token and database ID in settings
// Example Notion API call
const response = await fetch('https://api.notion.com/v1/pages', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${settings.notionToken}`,
    'Notion-Version': '2022-06-28',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    parent: { database_id: settings.notionDatabaseId },
    properties: {
      'Date': { date: { start: '2025-01-20' } },
      'Metric': { select: { name: 'Meetings' } },
      'Count': { number: 5 },
    },
  }),
});

Extension 2: Custom Metric Types

Let users define their own metrics with custom names, icons, and colors.

Challenge: Create a Property Inspector that allows adding/removing metrics, and dynamically create actions for each.

Implementation Ideas:

  • Store metric definitions in global plugin settings
  • Use streamDeck.settings.setGlobalSettings() for plugin-wide config
  • Generate unique action instances for each metric

Extension 3: Goals and Streaks

Add productivity goals and streak tracking.

Challenge: Let users set daily goals (e.g., “5 tasks minimum”) and show streak count for consecutive days meeting goals.

Features:

  • Goal threshold per metric
  • Streak counter with visual indicator
  • “Record streak” notification
  • Color changes based on goal progress
Goal Progress Display:
+------------------+
|   Tasks: 3/5     |  <- 3 completed, goal is 5
|   [===---]       |  <- Progress bar
|   ▂▄▆▃▇█▄        |
|   Streak: 7 days |
+------------------+

Extension 4: Pomodoro Integration

Combine with Pomodoro timer to auto-track focus sessions.

Challenge: When a Pomodoro completes, automatically increment “Focus Sessions” metric.

Implementation Ideas:

  • Inter-action communication
  • Shared state via global settings
  • Event system between actions

Extension 5: Weekly Report Email

Generate and email a weekly productivity report.

Challenge: Every Sunday at 6 PM, generate an HTML report and email it to the user.

Implementation Ideas:

  • HTML report template
  • Email service (SendGrid, Mailgun)
  • Scheduled trigger

Real-World Connections

Analytics Systems

This project teaches fundamentals used in production analytics:

Productivity Tracker Production Analytics
Daily aggregation Time-series databases
Metric types Event taxonomies
JSON file storage Data warehouses
Weekly rollups OLAP cubes
Mini bar charts Dashboard widgets

Data Pipelines

The webhook integration mirrors ETL patterns:

Your Plugin                        Production Pipeline
===========                        ===================
Button press                   ->  Event source
Metrics JSON                   ->  Data lake (S3, GCS)
Webhook to Notion              ->  ETL to warehouse
CSV export                     ->  Batch reports

Time-Series Data

Your date-based aggregation is the foundation of:

  • Application performance monitoring
  • Business intelligence dashboards
  • IoT sensor data collection
  • Financial trading systems

Books That Will Help

Topic Book Key Chapters
Type-safe state Effective TypeScript by Vanderkam Items 28-31: Valid states, branded types
File I/O patterns Node.js Design Patterns by Casciaro Ch. 5: Async patterns, Ch. 11: Streams
Canvas drawing HTML5 Canvas by Steve Fulton Ch. 2-4: Drawing, images, animation
Date handling date-fns documentation All guides on formatting & comparison
Data visualization The Visual Display of Quantitative Information by Tufte Principles of small multiples
API design Designing Data-Intensive Applications by Kleppmann Ch. 4: Encoding, Ch. 11: Stream processing

Self-Assessment Checklist

Before considering this project complete, verify your understanding:

Conceptual Understanding

  • Can you explain where streamDeck.plugin.dataPath points and why?
  • Can you describe atomic file writes and why they matter?
  • Can you explain how to handle midnight rollover without missing any presses?
  • Can you describe the tradeoffs between storing raw events vs daily aggregates?
  • Can you explain how to render useful information in 72 pixels?

Implementation Skills

  • Can you read and write JSON files with corruption handling?
  • Can you calculate and track “today” in the user’s local timezone?
  • Can you render a bar chart using node-canvas?
  • Can you generate properly escaped CSV files?
  • Can you implement retry logic for webhook failures?

Data Design

  • Is your metrics schema versioned for future migrations?
  • Do you handle missing days in the week gracefully?
  • Can your storage handle years of data without performance issues?
  • Is your date format consistent and parseable?
  • Can you add new metric types without losing existing data?

Edge Cases

  • What happens if the user’s clock is set to the wrong date?
  • What happens if the JSON file is deleted while the plugin is running?
  • What happens if the webhook endpoint is permanently down?
  • What happens if the user rapidly presses the button 100 times?
  • What happens at midnight during daylight saving time transitions?

Code Quality

  • Is file I/O properly error-handled?
  • Are timers cleaned up when the plugin disposes?
  • Is the in-memory state consistent with the persisted state?
  • Are all promises properly awaited or handled?
  • Can another developer understand your data flow?

Real-World Readiness

  • Can you track a metric for an entire month without issues?
  • Does the weekly chart render correctly on both Stream Deck Standard and XL?
  • Can you export data that opens correctly in Excel?
  • Does the webhook integration work with at least one external service?
  • Would you actually use this plugin daily?

The Core Question You’ve Answered

“How do I build persistent data storage with time-based aggregation and data visualization within the constraints of a Stream Deck plugin?”

By completing this project, you have demonstrated:

  1. Persistent Storage Design: Building a file-based database that survives any restart scenario
  2. Time-Based Aggregation: Tracking data by day while handling timezone and midnight edge cases
  3. Constraint-Driven Visualization: Creating meaningful data displays in tiny pixel spaces
  4. Integration Patterns: Connecting local data to external services via webhooks

These skills transfer directly to:

  • Building monitoring dashboards
  • Implementing analytics systems
  • Creating IoT data collectors
  • Designing time-series databases

You now understand that even “simple” persistence requires careful thought about corruption, timezones, and concurrent access. Every production system faces these challenges.


Project Guide Version 1.0 - December 2025