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
- Learning Objectives
- Deep Theoretical Foundation
- Complete Project Specification
- Real World Outcome
- Solution Architecture
- Phased Implementation Guide
- Testing Strategy
- Common Pitfalls & Debugging
- Extensions & Challenges
- Resources
- 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.dataPathand 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(), anddifferenceInDays()to avoid the notorious pitfalls of JavaScript’sDateobject. -
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)

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! |
+-------------------+

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

// 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;
}

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

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

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)

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)

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

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:
- Plugin Data Persistence - Stream Deck provides a data directory for each plugin. Where is it? How do you read/write files there?
- Reference: Stream Deck SDK - Settings Guide
- 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
- 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
- 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

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) |
+----------------+ +------------------+

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 |
+--------------+

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:
- 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 - Create JSON Store (
src/storage/json-store.ts)- Implement
loadMetrics()with corruption handling - Implement
saveMetrics()with atomic write - Create default metrics structure
- Implement
- Create Metrics Service (
src/services/metrics-service.ts)- Initialize on plugin start
- Implement
increment(metricId: string) - Simple save after each increment
- Wire Up Counter Action
- On
keyDown, callmetricsService.increment() - Update key title with new count
- Test restart persistence
- On
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:
- Add Date Utilities (
src/utils/date.ts)- Install and configure
date-fns - Create
getTodayString(),isToday()helpers - Create
getWeekDays()for chart data
- Install and configure
- Create Day Tracker (
src/services/day-tracker.ts)- Track current day string
- Set timer for midnight rollover
- Emit event on day change
- Update Data Structure
- Change from single counter to daily records array
- Implement migration from old format
- Add date to each record
- 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:
- Install Canvas (
npm install canvas)- May need system dependencies (see canvas npm docs)
- Test basic canvas creation
- 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
- Integrate with Action
- Call renderer on each update
- Use
setImage()to update key - Highlight today’s bar differently
- 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:
- Implement Long Press Detection
- Use
onKeyDownandonKeyUptiming - Threshold: 1.5+ seconds = long press
- Visual feedback during hold
- Use
- 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
- File Output
- Create export directory if needed
- Generate dated filename
- Write file with error handling
- 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:
- Create Webhook Service (
src/services/webhook-sender.ts)- Configure from Property Inspector settings
- Queue for retry on failure
- Exponential backoff
- Add Property Inspector Fields
- Webhook URL input
- Authorization header input
- Enable/disable toggle
- Test connection button
- Integrate with Counter Action
- Send webhook after increment
- Don’t block button response
- Show webhook status on key (small icon)
- 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:
- Multi-Metric Support
- Property Inspector dropdown to select metric
- Create new metrics via settings
- Each action instance tracks different metric
- Summary Action
- Shows all metrics for the week
- Cycles through metrics on press
- Pie chart or list view
- Reset Functionality
- Confirmation dialog (double-press)
- Reset single metric vs all metrics
- Preserve historical data
- 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:
-
Data Structure: Where does plugin data persist across restarts? (Hint:
streamDeck.plugin.dataPath) -
Midnight Handling: How do you handle midnight rollover? (Timer? Check on each press? Both?)
-
Visualization: How do you render a readable bar chart in 72 pixels? (Max 7-8 bars with spacing)
-
JSON Schema: What JSON structure stores daily counts efficiently?
-
Export Format: What format should the CSV export use? What columns?
-
Timezone: How do you ensure “today” matches the user’s local date?
-
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:
- Weekly aggregation needs to iterate through days anyway - O(1) lookup doesn’t help
- CSV export naturally maps to array of records
- Easier to implement “last N days” queries
- 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:
- “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
- Discuss
- “Explain your approach to time-based data aggregation.”
- Discuss daily records, timezone handling
- Explain midnight rollover logic
- “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
- “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)
- “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.dataPathpoints 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:
- Persistent Storage Design: Building a file-based database that survives any restart scenario
- Time-Based Aggregation: Tracking data by day while handling timezone and midnight edge cases
- Constraint-Driven Visualization: Creating meaningful data displays in tiny pixel spaces
- 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