59
PEAK DAY · JUL 6 2025
59.11 kWh
3.9× over threshold
MONTHLY RECORD · AUG 2025
406 kWh
Monthly threshold exceeded
TOTAL ALERTS PARSED
200+
Jun 2025 → May 2026
OPEN SOURCE PROJECT

Inwood
Electricity
Dashboard

A self-updating electricity dashboard that automatically pulls PSE&G threshold notifications from Gmail, parses kWh readings, and renders live usage trends — with zero servers and zero cost.

SCROLL TO EXPLORE
200+ Emails Parsed
59.1 Peak kWh Day
$0 Monthly Cost
6hr Cache TTL
2 API Subrequests
Architecture

How it all connects

📧
Source
PSE&G Gmail
MyMeter alerts
Function
Pages Worker
/api/meter-data
🗄
Cache
Cloudflare KV
6 hour TTL
📊
Frontend
Static HTML
No framework
👀
Output
Live Dashboard
pages.dev URL
01
🔄
Auto-Refreshing
Every page load checks the KV cache. If stale, re-fetches Gmail automatically. New PSE&G emails appear within 6 hours — no manual steps.
02
🚫
Zero Infrastructure
No servers. No databases. No cron jobs. Cloudflare Pages serves the static frontend and runs the Function on demand at the edge.
03
Minimal API Calls
Only 2 subrequests per refresh — one OAuth token exchange and one paginated Gmail messages.list. Snippets come inline, no second fetch needed.
04
📈
Rich Visualization
Bar chart, monthly summary cards, and a fully sortable, searchable data table — all rendered client-side from a single JSON endpoint.
Code

The key pieces

parseReadings() — snippet parser
function parseReadings(messages) {
  const readings = [];

  for (const msg of messages) {
    const s = msg.snippet;

    // "was 21.81 kWh at 05-20-26 12:00"
    const kwh  = s.match(/was\s+([\d.]+)\s+kWh/i);
    const date = s.match(/at\s+(\d{2}-\d{2}-\d{2})/i);

    if (!kwh || !date) continue;

    const [mm, dd, yy] = date[1].split('-');
    readings.push({
      date: `20${yy}-${mm}-${dd}`,
      kwh:  parseFloat(kwh[1]),
      type: detectType(s),
    });
  }
  return readings;
}
💡
PSE&G emails follow a consistent machine-generated format — two regexes extract everything needed from the snippet alone. No need to fetch the full email body.
onRequestGet() — cache + fetch
export async function onRequestGet({ env, request }) {
  const refresh = url.searchParams
    .get('refresh') === '1';

  // 1. Check KV cache (6hr TTL)
  if (!refresh) {
    const cached = await env.KV_METER_DATA
      .get(CACHE_KEY, { type: 'json' });
    if (cached && !isStale(cached))
      return respond({ ...cached, source: 'cache' });
  }

  // 2. OAuth2 token exchange
  const token = await getAccessToken(env);

  // 3. Fetch + parse Gmail messages
  const msgs     = await fetchMessageList(token);
  const readings = parseReadings(msgs);

  // 4. Write to KV cache
  await env.KV_METER_DATA.put(
    CACHE_KEY, JSON.stringify(payload),
    { expirationTtl: 21600 }
  );
}
🔑
The refresh token lives as an encrypted secret in Cloudflare — never in the repo. A single OAuth exchange gives a short-lived access token used only for that request.
Build Story

The road to working

💡
STEP 01 · IDEA
200 emails, zero visibility
A year of PSE&G threshold notifications sat unread in Gmail. The data was there — date, kWh, threshold type — in every single snippet. Just needed a bridge to make it visible.
🏗
STEP 02 · ARCHITECTURE
Cloudflare Pages + KV + Gmail API
Static HTML dashboard, a Pages Function as the API layer, KV for caching. Zero servers. The whole stack runs at the edge for free.
⚠️
STEP 03 · FIRST BLOCKER
Workers vs Pages confusion
Cloudflare's new unified UI merges Workers and Pages — creating a Worker instead of a Pages project served hello world instead of the dashboard. The fix was buried at the bottom of the create screen.
"Looking to deploy Pages? Get started" ← that link
🚫
STEP 04 · DEPLOY COMMAND HELL
The UI requires a deploy command it shouldn't need
Pages handles deployment automatically via GitHub integration. But the new UI forces a deploy command. The workaround that finally worked:
Deploy command: echo "deploy"
🐛
STEP 05 · THE BUG
Gmail batch API silently fails above 100 requests
The original approach sent all 200+ message IDs to Gmail's batch API in one request. Gmail's batch limit is 100 — exceed it and it returns nothing, no error. Zero emails parsed. Zero readings.
STEP 06 · THE FIX
messages.list already includes snippets
The batch API was never needed. Gmail's messages.list response already contains the snippet for each message inline. One API call, all the data. Dropped from 100+ subrequests to 2.
messages.list?q=from:MyMeter@email.pseg.com&maxResults=500
Tech Stack

Built with

☁️
Cloudflare Pages
Hosting + Serverless
Serves the static dashboard and runs the API function at the edge globally.
Free Tier
🗄
Cloudflare KV
Cache Layer
Globally distributed key-value store. Caches parsed Gmail data for 6 hours per request.
Free Tier
📧
Gmail API
Data Source
OAuth2 refresh token flow. messages.list with snippets — no per-message fetches needed.
google.com/apis
🐙
GitHub
CI/CD
Push to main → Cloudflare auto-deploys. Every commit is a production deploy.
bstef/inwood-electricity
📄
Vanilla HTML/CSS/JS
Frontend
No framework. No build step. Single index.html file with charts, table, and filters.
Zero Dependencies
🤖
Claude + Claude Code
AI-Assisted Dev
Built with AI pair programming — from initial design to diagnosing the Gmail batch API bug.
anthropic.com

See it
live

The dashboard updates automatically every 6 hours from live Gmail data.