walkthrough for understanding

pulse: how the whole thing works

A line-by-line tour of the two local CLIs — claude-stats and battery-stats — showing what each block does and why.

Two small projects, identical shape: collect → store → derive → render. Read in order, or jump to a script via the sidebar.

The data flow at a glance

Both projects follow the same five stages. Read across each row to compare claude-stats with battery-stats — same skeleton, different sources.

stage 1 · source
claude-stats
ccusage claude daily
+ ~/.claude/projects/*.jsonl
battery-stats
/sys/class/power_supply
+ /var/lib/upower
+ powertop (manual)
stage 2 · ingest
claude-stats
ingest-daily.sh
ingest-sessions.py
battery-stats
poll.sh (5 min)
ingest-upower.sh
powertop-capture.sh
stage 3 · store
claude-stats
claude.duckdb
battery-stats
battery.duckdb
stage 4 · derive
claude-stats
SQL inside build-dashboard.sh
battery-stats
aggregate-daily.sh (sessions + daily roll-up + health)
stage 5 · render
claude-stats
claude-stats-dashboard.html
(Plotly via CDN)
battery-stats
battery-stats-dashboard.html
(Plotly via CDN)

The shared shape — six principles

Why both projects look the same isn't accidental. These six choices show up in every file:

DuckDB, not Postgres or SQLite

Single file, embedded, no daemon. SQL feels like Postgres (window functions, CTEs, AT TIME ZONE). Scans 50k rows in <1 ms. Backup = cp. Reset = rm.

Raw + derived, never just derived

Ingesters write rows that map 1:1 with their source. Roll-ups are pure SQL on top of those rows — re-derivable any time. If a derivation rule changes (e.g. the phantom-session filter), only the aggregator changes; no backfill needed.

Self-contained HTML dashboards

No web server, no build step, no JS framework. build-dashboard.sh queries DuckDB, embeds the result as a const DATA = {…} blob, ships it with Plotly via CDN. Open in browser. Done.

systemd user timers with Persistent=true

Laptop suspended at 02:00? Missed runs fire on next boot. No cron, no fragile PATH-from-cron hacks, no missed days.

Naive UTC in, IST out

Every timestamp is stored as a naive TIMESTAMP in UTC. Display queries wrap with (col AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Kolkata'. A single AT TIME ZONE silently no-ops — a real bug that bit once and shaped the rule.

Symlink install

Source of truth = the pulse git repo. install.sh symlinks each bin/, fish/, systemd/ file into the live locations. git pull = instant deploy. DBs live outside the repo and are gitignored.

claude-stats: 4 scripts + schema · battery-stats: 5 scripts + schema

claude-stats · ingest-daily.sh

~/Documents/pulse/claude-stats/bin/ingest-daily.sh · 113 lines · bash

The cost ingester. Asks ccusage for the last 8 days of Claude Code usage, reshapes the JSON with jq, and upserts into two DuckDB tables. Run nightly by systemd; rolling-rewrite window means old rows are immutable.

▶ingest-daily.sh7 blocks · the cost pipeline
ingest-daily.shL1–L15 · setup
#!/usr/bin/env bash
# Ingest Claude Code daily usage from ccusage into DuckDB.
# Usage: ingest-daily.sh [SINCE_YYYYMMDD]
# Default SINCE = 8 days ago (always keeps the recent week fresh).

set -euo pipefail

# Cron-safe PATH. Node comes from nvm; source nvm.sh so we follow the
# `default` alias instead of pinning a version dir that rots on upgrade.
export NVM_DIR="${HOME}/.nvm"
set +eu
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1
set -eu
export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}"

why

set -euo pipefail turns silent failures loud: -e exits on any error, -u on unset variables, -o pipefail on any pipe-segment failure. Without pipefail, ccusage | jq … would swallow ccusage crashes.

systemd timers inherit a minimal PATH, so we source nvm to get npx on PATH. Sourcing follows the default alias — a Node upgrade needs no edits. (An earlier pinned …/node/vX/bin path 127'd the day nvm bumped Node, freezing the job for days.) nvm.sh isn't set -eu clean, so the set +eu … set -eu guard keeps strict mode everywhere else.

ingest-daily.shL11–L18 · paths & tempdir
DB="${HOME}/Documents/claude-stats/claude.duckdb"
DUCKDB="${HOME}/.local/bin/duckdb"
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT

SINCE="${1:-$(date -d '8 days ago' +%Y%m%d)}"
CSV="${TMPDIR}/usage.csv"
PROJ_CSV="${TMPDIR}/projects.csv"

why

The trap 'rm -rf "$TMPDIR"' EXIT guarantees the temp dir is cleaned up even if the script crashes — no leaked /tmp garbage.

The 8-day window is the heart of the design. Every run rewrites the last 8 days; rows older than that are immutable. This means ccusage can refine pricing for recent days (it re-reads JSONLs) without rewriting all history.

ingest-daily.shL20–L37 · daily totals via ccusage + jq
# (1) per-(date, model) totals  — feeds daily_usage
# ccusage v19 split agents under subcommands; top-level `daily` now aggregates
# all agents and drops modelBreakdowns. `claude daily` preserves the legacy shape.
npx --yes ccusage@latest claude daily --since "$SINCE" --json \
  | jq -r '
      .daily[] as $d
      | $d.modelBreakdowns[]
      | [
          $d.date,
          .modelName,
          (.inputTokens // 0),
          (.outputTokens // 0),
          (.cacheCreationTokens // 0),
          (.cacheReadTokens // 0),
          (.cost // 0)
        ]
      | @csv
    ' > "$CSV"

why

The claude subcommand is mandatory under ccusage v19+. Top-level ccusage daily aggregates Claude + Codex + OpenCode + Amp + pi together and drops modelBreakdowns — the JSON has no per-model split, so the next .modelBreakdowns[] blows up with "Cannot iterate over null". That's the v19 break.

The jq pipeline flattens nested JSON to one CSV row per (day × model). The // 0 pattern coalesces nulls to zero — old days may have cacheReadTokens: null before ccusage tracked that.

@csv handles quoting automatically — model names like claude-haiku-4-5-20251001 have no commas, but defensive quoting costs nothing.

ingest-daily.shL39–L57 · per-project breakdown
# (2) per-(project, date, model) breakdown — feeds project_daily_usage.
# Uses --instances to add the project_path dimension.
npx --yes ccusage@latest claude daily --since "$SINCE" --instances --breakdown --json \
  | jq -r '
      .projects
      | to_entries[] as $p
      | $p.value[] as $d
      | $d.modelBreakdowns[] as $m
      | [
          $p.key,
          $d.date,
          $m.modelName,
          ($m.inputTokens // 0),
          ($m.outputTokens // 0),
          ($m.cacheCreationTokens // 0),
          ($m.cacheReadTokens // 0),
          ($m.cost // 0)
        ]
      | @csv
    ' > "$PROJ_CSV"

why

The same ccusage call with --instances changes the JSON shape: there's no top-level .daily[] anymore; instead .projects is a map from project_path (e.g. -home-abhishek-chatterjee) to the daily array.

to_entries[] turns that map into {key, value} pairs — $p.key becomes the project path, $p.value becomes the array of daily rows.

Why both calls? Each total in project_daily_usage grouped by (date, model) is guaranteed to sum to the matching daily_usage row — to the cent. That reconciliation invariant is the whole reason to store both.

ingest-daily.shL59–L64 · early-exit guard
ROWS=$(wc -l < "$CSV")
PROJ_ROWS=$(wc -l < "$PROJ_CSV")
if [[ "$ROWS" -eq 0 && "$PROJ_ROWS" -eq 0 ]]; then
  echo "[ingest] no rows from ccusage since $SINCE — nothing to do"
  exit 0
fi

why

If ccusage returned no rows (e.g. you had no Claude activity for 8 days), don't open DuckDB at all. Opening + closing the file on a no-op still costs a write transaction and confuses the modification timestamp.

An exit 0 here is intentional — "nothing happened" is a success, not a failure. systemd treats this as a clean run.

ingest-daily.shL66–L84 · daily_usage upsert
"$DUCKDB" "$DB" <<SQL
CREATE OR REPLACE TEMP TABLE staging AS
SELECT * FROM read_csv('$CSV',
  header=false,
  columns={
    'date': 'DATE',
    'model': 'VARCHAR',
    'input_tokens': 'BIGINT',
    'output_tokens': 'BIGINT',
    'cache_creation_tokens': 'BIGINT',
    'cache_read_tokens': 'BIGINT',
    'cost': 'DOUBLE'
  }
);

INSERT OR REPLACE INTO daily_usage
  (date, model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cost, ingested_at)
SELECT date, model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cost, CURRENT_TIMESTAMP
FROM staging;

why

The bash heredoc (<<SQL … SQL) lets us shell-interpolate $CSV into the SQL string. The CSV staging table + INSERT OR REPLACE pattern is DuckDB's idiomatic upsert.

The columns={…} dict tells DuckDB the schema explicitly — never auto-infer for production ingest. Auto-inference silently picks VARCHAR for an all-null column, which breaks downstream sums.

INSERT OR REPLACE uses the table's PK (date, model) — any row that already exists gets fully replaced. Perfect for re-running over the 8-day window.

ingest-daily.shL86–L110 · project_daily_usage upsert + summary
CREATE OR REPLACE TEMP TABLE staging_proj AS
SELECT * FROM read_csv('$PROJ_CSV',
  header=false,
  columns={
    'project_path': 'VARCHAR',
    'date': 'DATE',
    'model': 'VARCHAR',
    'input_tokens': 'BIGINT',
    'output_tokens': 'BIGINT',
    'cache_creation_tokens': 'BIGINT',
    'cache_read_tokens': 'BIGINT',
    'cost': 'DOUBLE'
  }
);

INSERT OR REPLACE INTO project_daily_usage
  (project_path, date, model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cost, ingested_at)
SELECT project_path, date, model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cost, CURRENT_TIMESTAMP
FROM staging_proj;

SELECT
  '[ingest] upserted ' || (SELECT COUNT(*) FROM staging)
  || ' daily rows + ' || (SELECT COUNT(*) FROM staging_proj)
  || ' project-day rows (since $SINCE)' AS msg;
SQL

why

Same upsert pattern as before, just with the extra project_path dimension. PK is (project_path, date, model) — a project with 3 models over 8 days produces up to 24 rows, all idempotent.

The final SELECT returns a one-row summary so systemd's journal shows exactly how much was upserted. This is your audit trail when you're wondering "did the timer actually fire?"

claude-stats · ingest-sessions.py

~/Documents/pulse/claude-stats/bin/ingest-sessions.py · 234 lines · python

Walks every .jsonl under ~/.claude/projects/ and aggregates per-conversation stats: message counts, tool calls, skill invocations, max context size. Per-project cost data lives elsewhere (project_daily_usage, written by ingest-daily.sh) — this script only handles the local-only signal that ccusage can't see.

▶ingest-sessions.py5 blocks · JSONL walker
ingest-sessions.pyL1–L31 · imports & paths
#!/usr/bin/env python3
"""Ingest Claude Code session data into the local DuckDB.

Source: JSONL files at ~/.claude/projects/*/*.jsonl  (per-conversation detail).

Writes: conversations, conversation_tool_usage, conversation_skill_usage.
Idempotent via INSERT OR REPLACE.
"""

import csv, json, os, shutil, subprocess, sys, tempfile
from collections import Counter
from pathlib import Path

HOME = Path.home()
PROJECTS = HOME / ".claude" / "projects"
DB = HOME / "Documents" / "claude-stats" / "claude.duckdb"
DUCKDB = HOME / ".local" / "bin" / "duckdb"

# Only needs python3 (already running) + duckdb (absolute path above); no node.
os.environ["PATH"] = (
    f"{HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:" + os.environ.get("PATH", "")
)

why

Cron-safe PATH, but lighter than the bash script: this one needs duckdb from ~/.local/bin, not npx — so it skips nvm entirely and never pinned a Node version worth rotting.

Standard-lib only — csv, json, Counter, Path. No pip dependencies means nothing to break across Python upgrades. Even shutil + tempfile are stdlib.

ingest-sessions.pyL34–L45 · main vs subagent detection
def classify_path(path: Path):
    """Return (kind, project_path, parent_session_id) from a JSONL file path."""
    # Main:     .claude/projects/<project>/<uuid>.jsonl
    # Subagent: .claude/projects/<project>/<parent_uuid>/subagents/<agent-id>.jsonl
    parts = path.relative_to(PROJECTS).parts
    project_path = parts[0]
    if len(parts) == 2:
        return "main", project_path, None
    if len(parts) >= 3 and "subagents" in parts:
        parent_uuid = parts[1]
        return "subagent", project_path, parent_uuid
    return "main", project_path, None

why

Claude Code stores subagent transcripts in a nested folder. We classify by path shape, not file contents — much cheaper than parsing every JSONL twice.

The parent UUID becomes conversations.parent_session_id, so the dashboard can show "this subagent was spawned by that main conversation."

ingest-sessions.pyL48–L109 · the JSONL walker (the heart)
def parse_jsonl(path: Path):
    session_id = path.stem
    kind, project_path, parent_sid = classify_path(path)
    started_at = ended_at = cwd = git_branch = summary = None
    n_user = n_assistant = n_tool_calls = 0
    max_ctx = 0
    max_ctx_model = ""
    tools: Counter = Counter()
    skills: Counter = Counter()

    with path.open(errors="replace") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                d = json.loads(line)
            except json.JSONDecodeError:
                continue  # tolerate truncated/in-progress files

            ts = d.get("timestamp")
            if ts:
                if started_at is None or ts < started_at: started_at = ts
                if ended_at  is None or ts > ended_at:  ended_at = ts

            t = d.get("type")
            if t == "assistant":
                n_assistant += 1
                msg = d.get("message", {}) or {}
                usage = msg.get("usage") or {}
                if usage:
                    ctx = ((usage.get("input_tokens") or 0)
                         + (usage.get("cache_creation_input_tokens") or 0)
                         + (usage.get("cache_read_input_tokens") or 0))
                    if ctx > max_ctx:
                        max_ctx = ctx
                        max_ctx_model = msg.get("model") or ""
                for block in (msg.get("content") or []):
                    if isinstance(block, dict) and block.get("type") == "tool_use":
                        name = block.get("name") or "unknown"
                        tools[name] += 1
                        n_tool_calls += 1
                        if name == "Skill":
                            sk = (block.get("input") or {}).get("skill")
                            if sk: skills[sk] += 1

why

One pass through each JSONL — every line is a JSON object (a conversation event). We track extremes (min/max timestamp = session span, max context = how big the conversation grew) and counters (tool calls, skill invocations).

The errors="replace" + JSONDecodeError → continue combo tolerates files Claude Code is still writing to. Lossy > crashing on a half-written line.

The "max context" calculation sums three token fields because Claude Code's usage shape splits fresh tokens from cached ones. Tracking this lets the dashboard show context-pressure curves per conversation.

ingest-sessions.pyL144–L184 · DuckDB load via heredoc SQL
sql = f"""
CREATE OR REPLACE TEMP TABLE staging_conv AS
SELECT * FROM read_csv('{tmpdir}/conversations.csv',
  header=false,
  columns={{
    'session_id': 'VARCHAR', 'project_path': 'VARCHAR', 'cwd': 'VARCHAR',
    'git_branch': 'VARCHAR', 'kind': 'VARCHAR', 'parent_session_id': 'VARCHAR',
    'started_at': 'TIMESTAMP', 'ended_at': 'TIMESTAMP',
    'n_user_msgs': 'INTEGER', 'n_assistant_msgs': 'INTEGER',
    'n_tool_calls': 'INTEGER', 'max_context_tokens': 'BIGINT',
    'model': 'VARCHAR', 'summary': 'VARCHAR'
  }});

INSERT OR REPLACE INTO conversations
  (session_id, project_path, cwd, git_branch, kind, parent_session_id,
   started_at, ended_at, n_user_msgs, n_assistant_msgs, n_tool_calls,
   max_context_tokens, model, summary, ingested_at)
SELECT session_id, project_path, cwd, git_branch, kind, parent_session_id,
       started_at, ended_at, n_user_msgs, n_assistant_msgs, n_tool_calls,
       max_context_tokens, model, summary, CURRENT_TIMESTAMP
FROM staging_conv;
"""
proc = subprocess.run([str(DUCKDB), str(DB)], input=sql, text=True, capture_output=True)

why

Notice the pattern: write CSVs to a temp dir, then run one SQL script that loads all three at once. This is dramatically faster than per-row inserts — DuckDB streams CSV into staging in microseconds.

The {{ and }} in the f-string are literal braces (Python escapes them this way) because DuckDB's column dict is also brace-delimited.

The whole script is one transaction — if any step fails, DuckDB rolls back and the user sees stderr. No partial states.

ingest-sessions.pyL196–L230 · main loop & cleanup
def main():
    if not PROJECTS.exists():
        print("[ingest-sessions] no ~/.claude/projects yet — nothing to ingest")
        return

    tmpdir = Path(tempfile.mkdtemp(prefix="claude-stats-"))
    try:
        conv_rows, tool_rows, skill_rows = [], [], []
        for jsonl in sorted(PROJECTS.rglob("*.jsonl")):
            result = parse_jsonl(jsonl)
            if not result:
                continue
            conv, tools, skills = result
            conv_rows.append(conv)
            for tool_name, count in tools:
                tool_rows.append({"session_id": conv["session_id"], "tool_name": tool_name, "call_count": count})
            for skill_name, count in skills:
                skill_rows.append({"session_id": conv["session_id"], "skill_name": skill_name, "call_count": count})

        write_csv(conv_rows, [...], tmpdir / "conversations.csv")
        write_csv(tool_rows, [...], tmpdir / "tools.csv")
        write_csv(skill_rows, [...], tmpdir / "skills.csv")
        duckdb_load(str(tmpdir))
    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)

why

PROJECTS.rglob("*.jsonl") recursively finds every transcript — main conversations and nested subagents — in one call.

The try / finally guarantees the temp dir is cleaned up even on crashes. Same intent as the bash trap, different mechanism.

This script runs before claude-clean deletes per-project transcripts — that's wired up as a hook in ~/.config/fish/functions/claude-clean.fish. So even if you wipe your ~/.claude/projects/, the DuckDB still has every conversation.

claude-stats · schema.sql

~/Documents/pulse/claude-stats/schema.sql · 79 lines · sql

Five tables, all CREATE TABLE IF NOT EXISTS so install.sh can re-run safely. The schema versions itself in comments (v2 added subagent classification, v3 added skills, v4 split project cost out of conversations, v5 added agent_type for per-subagent name attribution).

▶schema.sql3 blocks · the 5 tables
schema.sqlL1–L14 · daily_usage
CREATE TABLE IF NOT EXISTS daily_usage (
    date DATE NOT NULL,
    model VARCHAR NOT NULL,
    input_tokens BIGINT NOT NULL DEFAULT 0,
    output_tokens BIGINT NOT NULL DEFAULT 0,
    cache_creation_tokens BIGINT NOT NULL DEFAULT 0,
    cache_read_tokens BIGINT NOT NULL DEFAULT 0,
    cost DOUBLE NOT NULL DEFAULT 0.0,
    ingested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (date, model)
);
CREATE INDEX IF NOT EXISTS idx_daily_usage_date ON daily_usage(date);
CREATE INDEX IF NOT EXISTS idx_daily_usage_model ON daily_usage(model);

why

Composite PK (date, model) matches how the dashboard groups: by day, split by model. ingested_at is a free audit column — you can see when a row was last touched without changing the data.

DEFAULT 0 on every numeric column means a future ccusage release adding a new token field won't break inserts that omit it.

schema.sqlL17–L58 · conversations + tool/skill detail
-- v2: one row per .jsonl file (main conversation OR subagent transcript)
CREATE TABLE IF NOT EXISTS conversations (
    session_id VARCHAR PRIMARY KEY,
    project_path VARCHAR,
    cwd VARCHAR,
    git_branch VARCHAR,
    kind VARCHAR NOT NULL DEFAULT 'main',     -- 'main' or 'subagent'
    parent_session_id VARCHAR,                -- NULL for main, parent UUID for subagent
    started_at TIMESTAMP,
    ended_at TIMESTAMP,
    n_user_msgs INTEGER NOT NULL DEFAULT 0,
    n_assistant_msgs INTEGER NOT NULL DEFAULT 0,
    n_tool_calls INTEGER NOT NULL DEFAULT 0,
    max_context_tokens BIGINT NOT NULL DEFAULT 0,
    model VARCHAR,
    summary VARCHAR,
    ingested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- v2: per-tool call counts per conversation
CREATE TABLE IF NOT EXISTS conversation_tool_usage (
    session_id VARCHAR,
    tool_name VARCHAR,
    call_count INTEGER NOT NULL,
    PRIMARY KEY (session_id, tool_name)
);

-- v3: per-skill call counts (extracted from Skill tool_use blocks)
CREATE TABLE IF NOT EXISTS conversation_skill_usage (
    session_id VARCHAR,
    skill_name VARCHAR,
    call_count INTEGER NOT NULL,
    PRIMARY KEY (session_id, skill_name)
);

why

Star schema: conversations is the fact table (one row per JSONL), the two usage tables are dimensions keyed by session_id. This lets the dashboard answer "top tools across all conversations" with one JOIN.

kind + parent_session_id turn the table into a forest: subagents point at their parent. The dashboard's session detail view uses this to render an indented tree.

Skills are tracked separately because skill names overlap with tool names (e.g. you have a commit skill, and there's also a Bash tool). Splitting them keeps the analytics clean.

schema.sqlL60–L78 · project_daily_usage (v4)
-- v4: per-(project, date, model) cost breakdown from
--     `ccusage claude daily --instances --breakdown --json`.
-- Same write semantics as daily_usage: INSERT OR REPLACE on a rolling 8-day window
-- driven by ingest-daily.sh. Rows older than 8 days are immutable. Adds project_path
-- so per-project cost can be windowed AND reconciled against daily_usage to the
-- cent (sum over project_path = daily row).
CREATE TABLE IF NOT EXISTS project_daily_usage (
    project_path VARCHAR NOT NULL,
    date         DATE    NOT NULL,
    model        VARCHAR NOT NULL,
    input_tokens          BIGINT NOT NULL DEFAULT 0,
    output_tokens         BIGINT NOT NULL DEFAULT 0,
    cache_creation_tokens BIGINT NOT NULL DEFAULT 0,
    cache_read_tokens     BIGINT NOT NULL DEFAULT 0,
    cost         DOUBLE  NOT NULL DEFAULT 0.0,
    ingested_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (project_path, date, model)
);

why

v4 is the reason both ccusage calls in ingest-daily.sh exist. Before v4, per-project cost was computed by ingest-sessions.py walking JSONLs — but JSONL costs drift from ccusage's authoritative prices, so the totals never matched.

Now: ccusage is the single source of truth for cost, the rollup table reconciles to daily_usage to the cent, and ingest-sessions.py stays focused on local-only signal (tool/skill counts).

claude-stats · cleanup-old.sh

~/Documents/pulse/claude-stats/bin/cleanup-old.sh · 31 lines · bash

365-day retention. Runs after each ingest. Short enough to show in full.

▶cleanup-old.sh1 block · full file
cleanup-old.shfull · L1–L31
#!/usr/bin/env bash
# Delete rows older than 365 days across all tables. Runs after each ingest.
set -euo pipefail

DB="${HOME}/Documents/claude-stats/claude.duckdb"
DUCKDB="${HOME}/.local/bin/duckdb"

"$DUCKDB" "$DB" <<'SQL'
DELETE FROM daily_usage         WHERE date < (CURRENT_DATE - INTERVAL 365 DAY);
DELETE FROM project_daily_usage WHERE date < (CURRENT_DATE - INTERVAL 365 DAY);

DELETE FROM conversations
WHERE ended_at IS NOT NULL
  AND CAST(ended_at AS DATE) < (CURRENT_DATE - INTERVAL 365 DAY);

-- Orphan tool/skill rows for conversations we just deleted
DELETE FROM conversation_tool_usage
WHERE session_id NOT IN (SELECT session_id FROM conversations);

DELETE FROM conversation_skill_usage
WHERE session_id NOT IN (SELECT session_id FROM conversations);

SELECT '[cleanup] daily_usage=' || (SELECT COUNT(*) FROM daily_usage)
  || ' project_daily=' || (SELECT COUNT(*) FROM project_daily_usage)
  || ' conversations=' || (SELECT COUNT(*) FROM conversations)
  || ' tool_rows=' || (SELECT COUNT(*) FROM conversation_tool_usage)
  || ' skill_rows=' || (SELECT COUNT(*) FROM conversation_skill_usage) AS msg;
SQL

why

Note the heredoc is <<'SQL' with quoted delimiter — that disables shell interpolation inside the SQL block. No variables to expand here, so quoted is safer.

Why delete orphan rows manually? DuckDB doesn't enforce foreign keys, so deleting a conversation doesn't cascade to its tool/skill rows. The two DELETE … NOT IN queries do the cascade explicitly.

The final summary SELECT appears in systemd's journal — you can grep [cleanup] across boots to see retention working over time.

claude-stats · build-dashboard.sh

~/Documents/pulse/claude-stats/bin/build-dashboard.sh · 959 lines · bash + embedded HTML/JS

Too big to walk line-by-line, but the pattern repeats throughout: query DuckDB → embed JSON in HTML → render with Plotly. We'll look at the spine.

▶build-dashboard.sh3 blocks · the dashboard pattern
build-dashboard.shpattern · query helper
# q() — run a DuckDB SQL query, capture JSON output into a shell var.
# Used everywhere: each chart gets its own q "..." block.
q() {
  "$DUCKDB" -json "$DB" "$1"
}

# Example: cost over time
COST_TS=$(q "
  SELECT
    (date::TIMESTAMP AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Kolkata' AS day,
    SUM(cost) AS cost
  FROM daily_usage
  WHERE date >= CURRENT_DATE - INTERVAL ${DAYS} DAY
  GROUP BY day
  ORDER BY day
")

why

The tiny q() wrapper lets every chart be a single shell variable. DuckDB's -json flag emits a JSON array per query — perfect to drop straight into a JS const.

The timezone double-wrap is the bug-bait pattern: AT TIME ZONE 'UTC' first attaches the naive timestamp to UTC, then AT TIME ZONE 'Asia/Kolkata' shifts it. A single AT TIME ZONE silently no-ops because DuckDB doesn't know what zone the naive value was in.

build-dashboard.shpattern · embedding data in HTML
cat > "$OUT" <<HTML
<!doctype html>
<html><head>
  <script src="https://cdn.plot.ly/plotly-2.35.0.min.js"></script>
  <style>${CSS}</style>
</head><body>
  <div id="chart-cost"></div>
  <div id="chart-tokens"></div>
  <script>
    const DATA = {
      cost_ts: ${COST_TS:-[]},
      tokens_ts: ${TOKENS_TS:-[]},
      top_projects: ${TOP_PROJECTS:-[]},
      // ... a dozen more
    };
    renderCost();
    renderTokens();
  </script>
</body></html>
HTML

why

This is the whole "no build step" trick. The bash heredoc interpolates JSON arrays directly into a JS object literal. No bundler, no npm, no static files — one self-contained HTML.

The ${COST_TS:-[]} default-empty-array pattern protects against an empty DuckDB (fresh install, no data yet). Without it, the JS parse would die on cost_ts: ,.

Plotly via CDN means the user needs network on first open, but the page caches and works offline thereafter. Acceptable trade-off for keeping the dashboard build a 1-second shell script.

build-dashboard.shpattern · per-chart JS renderer
    function renderCost() {
      const trace = {
        x: DATA.cost_ts.map(r => r.day),
        y: DATA.cost_ts.map(r => r.cost),
        type: 'bar',
        marker: { color: '#a78bfa' }
      };
      Plotly.newPlot('chart-cost', [trace], {
        paper_bgcolor: '#000',
        plot_bgcolor:  '#000',
        font: { color: '#f5f5f5' },
        margin: { t: 8, r: 8, b: 30, l: 50 }
      }, { displayModeBar: false });
    }

why

Every chart has the same shape: map the rows into Plotly's {x, y, …} trace format, call Plotly.newPlot with the AMOLED palette. Once you've done one, the rest copy/paste.

displayModeBar: false hides Plotly's toolbar — keeps the dashboard chrome-free.

Adding a new chart = a q() block + a JSON key + a renderXxx() function + a <div id=…>. Four small edits. No re-install (the script lives in ~/Documents/claude-stats/bin/ as a symlink to the repo).

Skipped here: the actual ~30 chart blocks, plus the embedded CSS (~200 lines of AMOLED variables and grid layout). All follow the q()→DATA→render() pattern shown above.

Switching projects → battery-stats

battery-stats · poll.sh

~/Documents/pulse/battery-stats/bin/poll.sh · 81 lines · bash

The heartbeat. Every 5 minutes, systemd fires this: read battery sysfs + screen state, append one row to battery_samples, refresh derived tables. Designed to be cheap — <100ms wall clock — so it can run on battery without itself becoming the drain.

▶poll.sh5 blocks · the 5-min sampler
poll.shL1–L22 · raw sysfs reads
#!/bin/bash
# poll.sh — single battery + screen-state sample, append to DuckDB
set -euo pipefail

DB="$HOME/Documents/battery-stats/battery.duckdb"
DUCKDB="$HOME/.local/bin/duckdb"
BAT="/sys/class/power_supply/BAT0"
AC="/sys/class/power_supply/AC"

[ -d "$BAT" ] || { echo "no BAT0" >&2; exit 0; }

# raw sysfs reads (microunits)
energy_now_uwh=$(cat "$BAT/energy_now")
energy_full_uwh=$(cat "$BAT/energy_full")
energy_full_design_uwh=$(cat "$BAT/energy_full_design")
power_now_uw=$(cat "$BAT/power_now" 2>/dev/null || echo 0)
voltage_uv=$(cat "$BAT/voltage_now")
capacity=$(cat "$BAT/capacity")
cycles=$(cat "$BAT/cycle_count" 2>/dev/null || echo 0)
state=$(cat "$BAT/status" | tr '[:upper:]' '[:lower:]')
on_ac=$(cat "$AC/online" 2>/dev/null || echo 0)

why

The kernel exposes battery state at /sys/class/power_supply/BAT0/ — just files you cat. No D-Bus, no daemon, no permissions issues. Cheaper than calling upower.

Values are in microunits (µWh, µW, µV) — integer-precise, no float math at this stage. We convert to base units later, once, before writing.

The || echo 0 fallbacks tolerate kernels where power_now or cycle_count aren't exposed (some embedded controllers don't report them).

poll.shL24–L40 · screen-state via logind + GNOME ScreenSaver
# screen state — Wayland-native
idle_hint="false"
screensaver="false"
if [ -n "${XDG_SESSION_ID:-}" ]; then
    h=$(loginctl show-session "$XDG_SESSION_ID" -p IdleHint --value 2>/dev/null || echo "no")
    [ "$h" = "yes" ] && idle_hint="true"
fi
if command -v gdbus >/dev/null 2>&1; then
    s=$(timeout 2 gdbus call --session \
            --dest org.gnome.ScreenSaver \
            --object-path /org/gnome/ScreenSaver \
            --method org.gnome.ScreenSaver.GetActive 2>/dev/null || echo "(false,)")
    [[ "$s" == *"true"* ]] && screensaver="true"
fi
screen_active="true"
[ "$idle_hint" = "true" ] && screen_active="false"
[ "$screensaver" = "true" ] && screen_active="false"

why

Two independent signals for "is the screen actually on?" loginctl reports systemd-logind's idle hint (set by the compositor when there's no user input); gdbus asks GNOME ScreenSaver if the screen is locked.

"Screen active" requires both to say no — this is the strict definition because the analytical question is "how much battery did the screen actually consume", not "was the user theoretically there".

The timeout 2 guards against gdbus hanging if the GNOME bus is sluggish — poll.sh has a 5-min budget, not a 2-hour one.

poll.shL42–L51 · brightness
brightness_pct="NULL"
for bl in /sys/class/backlight/*/; do
    if [ -d "$bl" ]; then
        cur=$(cat "$bl/brightness" 2>/dev/null || echo 0)
        max=$(cat "$bl/max_brightness" 2>/dev/null || echo 1)
        [ "$max" -gt 0 ] && brightness_pct=$(( cur * 100 / max ))
        break
    fi
done

why

Brightness lives at a vendor-specific path — could be intel_backlight, amdgpu_bl0, nvidia_0, etc. The glob + first-match handles all of them.

Stored as a literal SQL NULL (not a string) when unreadable — the schema accepts brightness_pct INTEGER NULL because some sessions (TTY, headless) have no backlight at all.

poll.shL53–L74 · convert & INSERT
# convert to base units
e_now=$(awk -v v="$energy_now_uwh" 'BEGIN{printf "%.6f", v/1000000}')
e_full=$(awk -v v="$energy_full_uwh" 'BEGIN{printf "%.6f", v/1000000}')
e_full_des=$(awk -v v="$energy_full_design_uwh" 'BEGIN{printf "%.6f", v/1000000}')
p_now=$(awk -v v="$power_now_uw" 'BEGIN{printf "%.6f", v/1000000}')
v_now=$(awk -v v="$voltage_uv" 'BEGIN{printf "%.6f", v/1000000}')

on_ac_bool="false"
[ "$on_ac" = "1" ] && on_ac_bool="true"

ts=$(date -u +"%Y-%m-%d %H:%M:%S")

"$DUCKDB" "$DB" <<SQL
INSERT INTO battery_samples
  (ts, energy_now_wh, energy_full_wh, energy_full_design_wh, power_now_w,
   voltage_v, capacity_pct, cycle_count, state, on_ac,
   screen_active, idle_hint, screensaver_active, brightness_pct)
VALUES
  ('$ts', $e_now, $e_full, $e_full_des, $p_now,
   $v_now, $capacity, $cycles, '$state', $on_ac_bool,
   $screen_active, $idle_hint, $screensaver, $brightness_pct)
ON CONFLICT (ts) DO NOTHING;
SQL

why

awk handles the float division because bash can't do floats natively. Six decimals of µ→base precision is enough for joules-per-hour math down to fractions of a milliwatt.

UTC timestamp via date -u — never local time. This is the rule that the timezone gotcha enforces: store naive UTC, convert at display time. Storing in IST would silently break any DST-aware downstream tool.

ON CONFLICT (ts) DO NOTHING makes the insert idempotent: if the timer fires twice within the same second (manual + scheduled), the second insert is a no-op.

poll.shL76–L80 · flock-guarded aggregate refresh
# Refresh derived tables so dashboard reflects every poll.
# Use flock so concurrent runs (e.g. manual + timer) don't collide on the DB.
LOCK="/tmp/battery-stats-aggregate.lock"
flock -n "$LOCK" -c "$HOME/Documents/battery-stats/bin/aggregate-daily.sh >/dev/null 2>&1" || true

why

After every poll we re-derive discharge_sessions and daily_battery so the dashboard is always fresh — no waiting for the nightly aggregate.

flock -n is non-blocking: if another aggregate is already running, this one just exits. || true swallows the lock-failure exit code so the poll itself still succeeds.

This is the only place battery-stats can race with itself — manual battery-stats refresh + scheduled poll firing at the same second. The lock is cheap insurance.

battery-stats · aggregate-daily.sh

~/Documents/pulse/battery-stats/bin/aggregate-daily.sh · 117 lines · bash + window-function SQL

The brain. Turns 5-minute raw samples into discharge sessions (from unplug to plug-in) and daily roll-ups. All the analytical insight lives in this script's SQL.

▶aggregate-daily.sh4 blocks · the window-function pipeline
aggregate-daily.shL15–L28 · wipe & the ordered CTE
-- wipe and rebuild derived tables (cheap; battery_samples is small)
DELETE FROM discharge_sessions;
DELETE FROM daily_battery;

-- step 1: tag each sample with session boundaries via on_ac transitions
WITH ordered AS (
    SELECT
        ts, energy_now_wh, energy_full_wh, energy_full_design_wh,
        capacity_pct, cycle_count, state, on_ac, screen_active,
        LAG(on_ac) OVER (ORDER BY ts) AS prev_on_ac,
        LAG(ts)    OVER (ORDER BY ts) AS prev_ts
    FROM battery_samples
),

why

Wipe-and-rebuild is fine here. battery_samples caps at ~25k rows even after a year (5-min poll × 24h × 365 = 105k, but with sleep/suspend it's far less). DuckDB rebuilds derived tables in <100ms.

LAG(on_ac) OVER (ORDER BY ts) is the magic — for each sample, it gives you the previous sample's on_ac value. That's how we'll detect "the moment AC was unplugged".

aggregate-daily.shL29–L43 · session boundaries
boundaries AS (
    SELECT
        *,
        -- new session whenever AC goes from true→false, or first row that is on battery
        CASE
            WHEN on_ac = false AND (prev_on_ac IS NULL OR prev_on_ac = true) THEN 1
            ELSE 0
        END AS new_session,
        -- gap to next sample, used for SOT integration; cap at 600s to handle suspends
        LEAST(
            COALESCE(EXTRACT(EPOCH FROM (LEAD(ts) OVER (ORDER BY ts) - ts)), 300),
            600
        ) AS interval_seconds
    FROM ordered
),

why

The new_session trick. Stamp a 1 on every "AC just got unplugged" row, 0 elsewhere. Then a running SUM over new_session gives every sample a session ID — DuckDB-idiomatic sessionization.

LEAD(ts) - ts gives the gap to the next sample. We cap at 600s (10 min) because a laptop suspend produces a multi-hour gap that would inflate SOT integration. Capping says "we don't trust gaps longer than 10 minutes — those are suspends, not active time."

The COALESCE(…, 300) handles the very last row, where LEAD(ts) is NULL — default to a typical 5-min interval.

aggregate-daily.shL44–L78 · session rollup with phantom-filter
tagged AS (
    SELECT *,
        SUM(new_session) OVER (ORDER BY ts ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS session_id
    FROM boundaries
    WHERE on_ac = false  -- only battery samples belong to sessions
)
INSERT INTO discharge_sessions
SELECT
    session_id,
    MIN(ts) AS start_ts, MAX(ts) AS end_ts,
    CAST(SUM(interval_seconds) AS BIGINT) AS duration_seconds,
    MAX(capacity_pct) AS start_pct, MIN(capacity_pct) AS end_pct,
    GREATEST(MAX(energy_now_wh) - MIN(energy_now_wh), 0) AS energy_used_wh,
    CAST(SUM(CASE WHEN screen_active THEN interval_seconds ELSE 0 END) AS BIGINT) AS sot_seconds,
    CAST(SUM(CASE WHEN NOT screen_active THEN interval_seconds ELSE 0 END) AS BIGINT) AS screen_off_seconds,
    -- avg drain across whole session (incl screen off)
    CASE WHEN SUM(interval_seconds) > 0
         THEN GREATEST(MAX(energy_now_wh) - MIN(energy_now_wh), 0) * 3600.0 / SUM(interval_seconds)
         ELSE NULL END AS avg_drain_w,
    -- avg drain ONLY when screen was on (the honest number)
    CASE WHEN SUM(CASE WHEN screen_active THEN interval_seconds ELSE 0 END) > 0
         THEN GREATEST(MAX(energy_now_wh) - MIN(energy_now_wh), 0) * 3600.0
              / SUM(CASE WHEN screen_active THEN interval_seconds ELSE 0 END)
         ELSE NULL END AS avg_drain_w_sot,
    -- extrapolated full-charge runtime at current SOT drain rate
    CASE WHEN SUM(CASE WHEN screen_active THEN interval_seconds ELSE 0 END) > 0
              AND GREATEST(MAX(energy_now_wh) - MIN(energy_now_wh), 0) > 0
         THEN MAX(energy_full_design_wh) /
              (GREATEST(MAX(energy_now_wh) - MIN(energy_now_wh), 0) * 3600.0
               / SUM(CASE WHEN screen_active THEN interval_seconds ELSE 0 END))
         ELSE NULL END AS projected_full_runtime_hours
FROM tagged
GROUP BY session_id
HAVING duration_seconds > 60
   AND (MAX(energy_now_wh) - MIN(energy_now_wh)) > 0.05;  -- skip phantoms

why

The phantom-session filter (the HAVING clause) is the result of a real bug. A 1-sample session — when you unplug, immediately re-plug — looks like a 0Wh, 0-second discharge. Those sessions polluted the dashboard with infinite-runtime projections.

Filter: duration > 60s AND energy_delta > 0.05 Wh. Anything smaller is noise.

SOT vs total drain is the most useful number in the dashboard. Total drain conflates screen-on with screen-off (suspend, idle). SOT drain answers "if I actually use this laptop, how long does the battery last?" — the honest number.

projected_full_runtime_hours = design_capacity / sot_drain_rate — extrapolates a single discharge session into "if it stayed at this rate from 100% to 0%, how many hours would I get?".

aggregate-daily.shL80–L112 · daily roll-up + health
-- step 2: daily roll-up (group by IST date so a 23:00 IST session belongs to today)
INSERT INTO daily_battery
SELECT
    DATE_TRUNC('day', (start_ts AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Kolkata')::DATE AS date,
    CAST(SUM(sot_seconds) / 60 AS INTEGER) AS sot_minutes,
    CAST(SUM(duration_seconds) / 60 AS INTEGER) AS discharge_minutes,
    SUM(energy_used_wh) AS total_discharge_wh,
    CASE WHEN SUM(sot_seconds) > 0
         THEN SUM(energy_used_wh) * 3600.0 / SUM(sot_seconds)
         ELSE NULL END AS avg_drain_w_sot,
    COUNT(*) AS n_sessions,
    NULL AS cycle_count_eod, NULL AS energy_full_wh_eod, NULL AS health_pct
FROM discharge_sessions
GROUP BY date;

-- backfill end-of-day cycle / energy_full from raw samples
UPDATE daily_battery d SET
    cycle_count_eod    = s.cycle_count,
    energy_full_wh_eod = s.energy_full_wh,
    health_pct         = s.energy_full_wh / NULLIF(s.energy_full_design_wh, 0) * 100
FROM (
    SELECT
        DATE_TRUNC('day', (ts AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Kolkata')::DATE AS date,
        FIRST(cycle_count    ORDER BY ts DESC) AS cycle_count,
        FIRST(energy_full_wh ORDER BY ts DESC) AS energy_full_wh,
        FIRST(energy_full_design_wh ORDER BY ts DESC) AS energy_full_design_wh
    FROM battery_samples
    GROUP BY date
) s
WHERE d.date = s.date;

why

The double AT TIME ZONE is the tz fix. Without the first AT TIME ZONE 'UTC', the literal stays naive and the conversion silently does nothing — your 23:30 IST session would attribute to tomorrow's UTC date.

The UPDATE … FROM (subquery) backfills end-of-day battery health: energy_full_wh / energy_full_design_wh × 100. Watching this curve drop over months tells you when the battery actually needs replacing.

FIRST(x ORDER BY ts DESC) is DuckDB-flavored "give me the last (chronologically) value of x per group" — equivalent to a window function but more compact.

battery-stats · ingest-upower.sh

~/Documents/pulse/battery-stats/bin/ingest-upower.sh · 78 lines · bash

A one-shot historical importer. UPower has been logging your battery to /var/lib/upower/history-*.dat since you installed the OS — months or years of free data. This script bulk-imports it.

▶ingest-upower.sh3 blocks · backfill from system logs
ingest-upower.shL10–L28 · battery model auto-detect
# UPower names files history-{rate,charge}-{MODEL}-{SERIAL}.dat. We read
# the kernel-reported model from sysfs and glob for matching files.
BATTERY_MODEL="${BATTERY_MODEL:-$(cat /sys/class/power_supply/BAT0/model_name 2>/dev/null | tr -d '[:space:]')}"

if [ -z "$BATTERY_MODEL" ]; then
    echo "could not detect battery model from /sys/class/power_supply/BAT0/model_name" >&2
    echo "set BATTERY_MODEL=<tag> to override (look in $HIST_DIR for history-rate-*.dat)" >&2
    exit 1
fi

RATE_FILE=$(ls "$HIST_DIR"/history-rate-"$BATTERY_MODEL"-*.dat 2>/dev/null | head -1 || true)
CHARGE_FILE=$(ls "$HIST_DIR"/history-charge-"$BATTERY_MODEL"-*.dat 2>/dev/null | head -1 || true)

why

Reading the model name from sysfs avoids hardcoding "L19M4PD3" or similar in the script — it works across laptops without edits. Override via env var if your battery's UPower filename uses a different tag.

The error message tells the user exactly what to do — point them at $HIST_DIR to look up the right tag. Good UX in CLI scripts is just "fail with the next step embedded."

ingest-upower.shL42–L57 · awk TSV→CSV transform
# convert TSV "epoch  value  state" into a CSV that DuckDB can COPY
awk -F'\t' '$1 ~ /^[0-9]+$/ {
    cmd = "date -u -d @" $1 " +\"%Y-%m-%d %H:%M:%S\""
    cmd | getline ts
    close(cmd)
    printf "\"%s\",%s,\"%s\"\n", ts, $2, $3
}' "$RATE_FILE" > "$WORK/rate.csv"

why

UPower stores history as epoch-second TSVs. DuckDB can COPY from CSV but can't natively parse Unix epochs in mixed-format columns. So we shell out to date -u -d @ via awk to convert.

cmd | getline ts runs the subshell and reads its stdout into a variable. close(cmd) is mandatory — otherwise awk reuses the same pipe across rows and you get the wrong timestamps.

The $1 ~ /^[0-9]+$/ guard skips any header or stray comment lines — only rows that start with an epoch get converted.

ingest-upower.shL60–L78 · DuckDB COPY + ON CONFLICT
CREATE TEMP TABLE staging_rate (ts TIMESTAMP, watts DOUBLE, state VARCHAR);
COPY staging_rate FROM '$WORK/rate.csv' (HEADER FALSE);
INSERT INTO upower_rate
SELECT ts, watts, state FROM staging_rate
ON CONFLICT (ts, state) DO NOTHING;

CREATE TEMP TABLE staging_charge (ts TIMESTAMP, percentage DOUBLE, state VARCHAR);
COPY staging_charge FROM '$WORK/charge.csv' (HEADER FALSE);
INSERT INTO upower_charge
SELECT ts, percentage, state FROM staging_charge
ON CONFLICT (ts, state) DO NOTHING;

why

COPY … FROM 'file.csv' is DuckDB's bulk-loader — orders of magnitude faster than per-row INSERT. For an ingest of 50k UPower samples, it's milliseconds vs minutes.

ON CONFLICT (ts, state) DO NOTHING makes the entire script safe to re-run. You can run it weekly, daily, whatever — duplicates are silently skipped because the PK already exists.

battery-stats · powertop-capture.sh

~/Documents/pulse/battery-stats/bin/powertop-capture.sh · 118 lines · bash + embedded python

Manual capture. PowerTop needs root to run and produces a notoriously messy CSV. This script asks for sudo via askpass, runs powertop for N seconds, then parses the "Top 10 Power Consumers" section with embedded Python.

▶powertop-capture.sh3 blocks · root + parse + store
powertop-capture.shL1–L24 · sudo askpass
#!/bin/bash
set -euo pipefail

DB="$HOME/Documents/battery-stats/battery.duckdb"
DUCKDB="$HOME/.local/bin/duckdb"
ASKPASS="${SUDO_ASKPASS:-$HOME/.local/bin/sudo-askpass}"

DURATION="${1:-30}"
shift || true
NOTES="${*:-}"

WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
CSV="$WORK/powertop.csv"

echo "running powertop for ${DURATION}s (sudo askpass)..."
SUDO_ASKPASS="$ASKPASS" sudo -A powertop --csv="$CSV" --time="$DURATION" >/dev/null 2>&1

why

sudo -A tells sudo to ask for the password via the SUDO_ASKPASS helper instead of the TTY. The user has a custom askpass at ~/.local/bin/sudo-askpass that pops a zenity dialog — this is essential because terminal-less invocations (Claude Code, scripts run via fish abbrs, etc.) can't prompt natively.

shift || true is the safe way to discard $1 when there might not be one — without || true, set -e would kill the script on an empty arg list.

powertop-capture.shL32–L87 · embedded Python parser
python3 - "$CSV" "$WORK/parsed.csv" <<'PYEOF'
import csv, sys, re
src, dst = sys.argv[1], sys.argv[2]
rows = []
with open(src, newline='', encoding='utf-8', errors='replace') as f:
    in_section = False
    rank = 0
    for line in f:
        stripped = line.strip().strip(';').strip()
        if 'Top 10 Power Consumers' in line or 'Overview of Software Power Consumers' in line:
            in_section = True; rank = 0
            continue
        if in_section:
            if not stripped or stripped.startswith('___') or stripped.startswith('* * *'):
                if rank > 0: in_section = False
                continue
            parts = [p.strip() for p in line.split(';')]
            if len(parts) >= 3 and any(parts):
                desc = max(parts, key=len)
                # try to pull wattage / wakeups
                ...
                rank += 1
                rows.append((rank, desc[:200], parts[0][:100], wk, pw, 'process'))
                if rank >= 20: in_section = False
PYEOF

why

Why embed Python in bash? PowerTop's CSV has section headers like ______; ; ; and unpredictable column orders — parsing it in awk would be fragile. Python with a state machine is the right tool, but installing a separate .py file would split the script.

python3 - args <<'PYEOF' … PYEOF is a bash trick: - means "read code from stdin" and the heredoc supplies it. Result: one file, two languages, no install.

The quoted 'PYEOF' disables shell interpolation inside the Python — necessary because the script uses $ in regexes that bash would otherwise mangle.

powertop-capture.shL92–L117 · DuckDB store
-- get next run_id
INSERT INTO powertop_runs (run_id, captured_at, duration_seconds, on_ac, notes)
SELECT
    COALESCE((SELECT MAX(run_id) FROM powertop_runs), 0) + 1,
    '$ts'::TIMESTAMP, $DURATION, $on_ac_bool,
    NULLIF('$NOTES', '');

CREATE TEMP TABLE staging (
    rank INTEGER, description VARCHAR, usage VARCHAR,
    wakeups_per_sec DOUBLE, pw_estimate_w DOUBLE, category VARCHAR
);
COPY staging FROM '$WORK/parsed.csv' (HEADER FALSE);

INSERT INTO powertop_top_processes
SELECT
    (SELECT MAX(run_id) FROM powertop_runs) AS run_id,
    rank, description, usage, wakeups_per_sec, pw_estimate_w, category
FROM staging
ON CONFLICT (run_id, rank, description) DO NOTHING;

why

Manual run_id autoincrement. DuckDB has SEQUENCE but the simpler MAX(run_id) + 1 is fine for a manual-only table — there's no concurrent-insert risk since powertop runs are user-triggered.

NULLIF('$NOTES', '') converts an empty notes string to SQL NULL so the column accurately distinguishes "no notes" from "user typed nothing".

Each powertop run produces one parent row in powertop_runs + up to 20 child rows in powertop_top_processes. Same star-schema pattern as conversations ↔ conversation_tool_usage in claude-stats.

battery-stats · schema.sql

~/Documents/pulse/battery-stats/schema.sql · 89 lines · sql

Six tables: battery_samples (raw poll), upower_rate + upower_charge (historical bulk), discharge_sessions + daily_battery (derived), and powertop_runs + powertop_top_processes (manual).

▶schema.sql2 blocks · raw + derived
schema.sqlL5–L23 · battery_samples (raw poller)
CREATE TABLE IF NOT EXISTS battery_samples (
    ts                  TIMESTAMP NOT NULL,
    energy_now_wh       DOUBLE NOT NULL,
    energy_full_wh      DOUBLE NOT NULL,
    energy_full_design_wh DOUBLE NOT NULL,
    power_now_w         DOUBLE NOT NULL,        -- positive = drain or charge magnitude
    voltage_v           DOUBLE NOT NULL,
    capacity_pct        INTEGER NOT NULL,
    cycle_count         INTEGER,
    state               VARCHAR NOT NULL,       -- charging | discharging | full | not-charging
    on_ac               BOOLEAN NOT NULL,
    screen_active       BOOLEAN NOT NULL,       -- IdleHint=no AND ScreenSaver inactive
    idle_hint           BOOLEAN NOT NULL,       -- raw IdleHint
    screensaver_active  BOOLEAN NOT NULL,
    brightness_pct      INTEGER,
    PRIMARY KEY (ts)
);

why

Single PK on ts — at 5-min cadence, you can never have two samples at exactly the same UTC second (and if you do, the ON CONFLICT DO NOTHING in the poller dedups).

Three "screen" columns (screen_active, idle_hint, screensaver_active) store both the derived signal AND its two inputs — so if the derivation rule changes, no data loss; you can re-derive from history.

schema.sqlL42–L69 · discharge_sessions + daily_battery
-- discharge sessions (derived nightly): one row per "unplug to plug-in or shutdown"
CREATE TABLE IF NOT EXISTS discharge_sessions (
    session_id          INTEGER PRIMARY KEY,
    start_ts            TIMESTAMP NOT NULL,
    end_ts              TIMESTAMP NOT NULL,
    duration_seconds    BIGINT NOT NULL,
    start_pct           INTEGER NOT NULL,
    end_pct             INTEGER NOT NULL,
    energy_used_wh      DOUBLE NOT NULL,
    sot_seconds         BIGINT NOT NULL,            -- screen-on-time during this session
    screen_off_seconds  BIGINT NOT NULL,
    avg_drain_w         DOUBLE,                     -- across whole session
    avg_drain_w_sot     DOUBLE,                     -- only when screen was on (the honest number)
    projected_full_runtime_hours DOUBLE             -- design / avg_drain_w_sot
);

-- daily roll-up: one row per calendar day
CREATE TABLE IF NOT EXISTS daily_battery (
    date                DATE PRIMARY KEY,
    sot_minutes         INTEGER NOT NULL,
    discharge_minutes   INTEGER NOT NULL,
    total_discharge_wh  DOUBLE NOT NULL,
    avg_drain_w_sot     DOUBLE,
    n_sessions          INTEGER NOT NULL,
    cycle_count_eod     INTEGER,                    -- end-of-day reading
    energy_full_wh_eod  DOUBLE,                     -- for decay tracking
    health_pct          DOUBLE                      -- energy_full / energy_full_design * 100
);

why

Both tables are fully derivable from battery_samples via aggregate-daily.sh. You can DELETE FROM discharge_sessions + run aggregate, and they rebuild identically. This is the "raw + derived, never just derived" principle in action.

health_pct is what makes long-term battery analytics possible. Watching the EOD column drop from 95% → 80% over a year tells you what no other tool does.

Cross-cutting patterns

A handful of techniques show up in both projects. They aren't unique to pulse — they're "the right way" we converged on after a year of small bugs.

1. systemd user timers, not cron (claude-stats nightly · battery-stats 5-min)

claude-stats-ingest.timerfull
[Unit]
Description=claude-stats: nightly ingest at 02:00 (Persistent — fires on next boot if missed)

[Timer]
OnCalendar=*-*-* 02:00:00
AccuracySec=15min
Persistent=true

[Install]
WantedBy=timers.target

why

Persistent=true is the critical flag. If the laptop is suspended at 02:00, the timer fires on next wake — no missed days, no manual catch-up. cron can't do this without third-party shims like anacron.

AccuracySec=15min tells systemd "don't fire all timers at exactly 02:00 on every machine" — it adds jitter so the system isn't slammed by every *-*-* 02:00:00 job at once.

2. The timezone double-AT-TIME-ZONE pattern (everywhere display-time IST is needed)

The rule: Store naive TIMESTAMP as UTC. To display as IST: (col AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Kolkata'. A single AT TIME ZONE is a silent no-op — DuckDB doesn't know what zone the naive value started in, so the conversion does nothing.
aggregate-daily.shtz pattern
-- ❌ wrong (silent no-op — does NOT shift)
DATE_TRUNC('day', start_ts AT TIME ZONE 'Asia/Kolkata')

-- ✓ right (attach to UTC first, then shift to IST)
DATE_TRUNC('day', (start_ts AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Kolkata')

why this is hard to spot

Both queries run. Both return timestamps. The wrong one returns the same naive value back unchanged — so for someone in UTC, it looks fine. The bug only shows up if you're in IST (or any non-UTC tz) and your evening sessions silently bucket into tomorrow's date.

The fix is mechanical (wrap with AT TIME ZONE 'UTC'), but you have to know to do it. Once bitten, twice paranoid — every display query in pulse uses the double pattern.

3. Self-contained HTML dashboards (both projects)

One file, no server

build-dashboard.sh queries DuckDB, embeds JSON into JS as a const literal, ships Plotly via CDN, writes one .html file. xdg-open on it; browser does the rest.

Why not React / SvelteKit / etc.

No npm. No build step. No framework upgrades to deal with. No node_modules. Pulse will run on a fresh Linux install in 30 seconds.

Why Plotly

Single CDN URL, dozens of chart types, zoom/pan built in, exports to PNG. The AMOLED theme (paper_bgcolor: '#000') makes the charts feel native to the OLED display.

The data-embed trick

const DATA = { cost_ts: ${COST_TS:-[]}, … }. Bash interpolates DuckDB's JSON output directly into a JS object literal. No fetch, no XHR, no async — the page is the data.

4. Symlink install (install.sh → ~/Documents/{claude,battery}-stats/)

install.shconceptual
ln -sf "$REPO/claude-stats/bin/ingest-daily.sh"     ~/Documents/claude-stats/bin/
ln -sf "$REPO/claude-stats/fish/claude-stats.fish"  ~/.config/fish/functions/
ln -sf "$REPO/claude-stats/systemd/*.timer"         ~/.config/systemd/user/
# (and the same for battery-stats)

why

Source of truth = the git repo. Live install = symlinks pointing at the repo. Edit a file in the repo → it's live immediately. git pull deploys.

The DBs live outside the symlink tree (in ~/Documents/{claude,battery}-stats/*.duckdb) so they're gitignored and never accidentally committed.

Fresh laptop: git clone + ./install.sh. Two commands. No package manager, no build, no Docker.

5. Raw + derived, never just derived (everywhere)

The rule: Ingest writes raw rows. Roll-ups are SQL queries on top. If the rule changes (e.g., phantom-session filter, retention window, max-context formula), only the SQL changes — no backfill. Sleep well.

This is why battery_samples stores idle_hint + screensaver_active separately from screen_active — if you ever decide "screen_active should require BOTH idle hint AND screensaver, AND brightness > 5%", you can re-derive instantly from history. If you'd only stored screen_active, that change would have no historical data.

That's it

Two CLIs, ten scripts, two DuckDB files, two HTML dashboards. Roughly 3,000 lines of shell + SQL + Python + minimal HTML/CSS/JS. No daemons, no servers, no npm, no Docker.

When something breaks (e.g. ccusage v19), the failure is loud (set -euo pipefail) and the fix is one or two lines. That's the whole point.