cross-cutting internals

Concerns that span both projects: the systemd-timer schedule, the UTC→IST display gotcha, and the file map covering the repo and the live install paths.

Schedule — systemd user timers

Three timers run in the background. All have Persistent=true, so a missed run (laptop off / asleep at firing time) executes on next boot. This was a recent migration from cron — see callout below.

timerscheduleservice does
battery-stats-poll.timer every 5 min Runs poll.sh → one row in battery_samples + chained aggregator under flock
battery-stats-aggregate.timer nightly 03:15 Runs ingest-upower.sh + aggregate-daily.sh + cleanup-old.sh
claude-stats-ingest.timer nightly 02:00 Runs ingest-daily.sh + ingest-sessions.py + cleanup-old.sh

Inspect

# list all pulse timers + when they fire next
systemctl --user list-timers 'battery-stats-*' 'claude-stats-*'

# last run logs
journalctl --user -u claude-stats-ingest.service -n 50
journalctl --user -u battery-stats-poll.service -n 50

# manually trigger
systemctl --user start claude-stats-ingest.service
Why we moved off cron
Previously claude-stats ran via a user crontab entry at 0 2 * * *. User cron has no catch-up: if the laptop is off at 02:00 the run is silently lost. This caused a 4-day data gap in May 2026. systemd timers with Persistent=true write a stamp file each fire and replay missed runs on next boot. install.sh step 7 idempotently removes the old cron entry.

The claude-clean hook

Your fish claude-clean function runs ingest-sessions.py as the first step before any per-project deletion. So conversations, conversation_tool_usage, and conversation_skill_usage all outlive the JSONL files — skill-name attribution is captured permanently before transcripts disappear.

The UTC → IST gotcha

Subtle DuckDB semantics that caused a real bug. Read this once and the code makes sense.

Storage convention

  • Every TIMESTAMP column in both DBs is naive (no offset) and stores UTC.
  • Writers (poll.sh, ingest-upower.sh) explicitly use date -u.
  • JSONL transcripts ship ISO-8601 with Z already.

Display: double AT TIME ZONE

-- correct: convert UTC → IST for display
STRFTIME((ts AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Kolkata', '%H:%M:%S')

Why two? DuckDB has two semantics for AT TIME ZONE:

  1. naive ts AT TIME ZONE 'X' = "treat this naive value as if it's in zone X" → returns a TIMESTAMPTZ
  2. TIMESTAMPTZ AT TIME ZONE 'X' = "convert to zone X, return naive" → returns a TIMESTAMP

So:

  • ts AT TIME ZONE 'Asia/Kolkata' on a naive UTC timestamp silently rebadges it as IST (no shift) — wrong.
  • (ts AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Kolkata' first promotes to TIMESTAMPTZ correctly tagged UTC, then converts to IST. The +05:30 shift actually happens.
If you swap timezones
Search-and-replace 'Asia/Kolkata' across: battery-stats/bin/{aggregate-daily.sh,build-dashboard.sh}, claude-stats/bin/build-dashboard.sh. Don't change the storage side (date -u in poll.sh) — UTC stays.

File map

Where everything lives in the repo (source of truth) and after install (live paths, all symlinks).

In the repo: ~/Documents/pulse/

pulse/
├── claude-stats/
│   ├── bin/
│   │   ├── ingest-daily.sh        # ccusage daily → DuckDB
│   │   ├── ingest-sessions.py     # JSONL walk + ccusage session
│   │   ├── cleanup-old.sh         # 365-day retention
│   │   └── build-dashboard.sh     # DuckDB → HTML+Plotly
│   ├── fish/claude-stats.fish     # CLI: dashboard, ingest, raw
│   ├── systemd/
│   │   ├── claude-stats-ingest.service
│   │   └── claude-stats-ingest.timer
│   └── schema.sql
├── battery-stats/
│   ├── bin/
│   │   ├── poll.sh                # every 5 min sample
│   │   ├── ingest-upower.sh       # bulk UPower history
│   │   ├── aggregate-daily.sh     # sessions + daily rollup
│   │   ├── powertop-capture.sh    # manual snapshot
│   │   ├── cleanup-old.sh
│   │   └── build-dashboard.sh
│   ├── fish/battery-stats.fish    # CLI: dashboard, powertop, ingest
│   ├── systemd/
│   │   ├── battery-stats-poll.{service,timer}
│   │   └── battery-stats-aggregate.{service,timer}
│   └── schema.sql
├── fish/
│   └── pulse-docs.fish            # opens docs/index.html in browser
├── docs/
│   ├── index.html                 # landing (you start here)
│   ├── claude-stats.html          # spoke 1
│   ├── battery-stats.html         # spoke 2
│   ├── internals.html             # spoke 3 (this page)
│   ├── _styles.css                # shared CSS (one place to retheme)
│   ├── _nav.js                    # shared sidebar/scrollspy
│   ├── claude-stats-dashboard.png
│   └── battery-stats-dashboard.png
├── install.sh                     # idempotent installer
└── README.md

After install (symlinks back to the repo)

~/Documents/claude-stats/
├── claude.duckdb                  # data (gitignored)
├── schema.sql                     → repo
└── bin/                           → repo

~/Documents/battery-stats/
├── battery.duckdb                 # data
├── schema.sql                     → repo
└── bin/                           → repo

~/.config/fish/functions/
├── claude-stats.fish              → repo
├── battery-stats.fish             → repo
└── pulse-docs.fish                → repo  (opens docs/index.html)

~/.config/systemd/user/
├── battery-stats-poll.{service,timer}       → repo
├── battery-stats-aggregate.{service,timer}  → repo
└── claude-stats-ingest.{service,timer}      → repo

Symlink-based install means git pull instantly updates the live system — no copy step. Edit files in ~/Documents/pulse/ not in the live paths.