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.
| timer | schedule | service 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
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
TIMESTAMPcolumn in both DBs is naive (no offset) and stores UTC. - Writers (
poll.sh,ingest-upower.sh) explicitly usedate -u. - JSONL transcripts ship ISO-8601 with
Zalready.
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:
- naive ts
AT TIME ZONE 'X'= "treat this naive value as if it's in zone X" → returns aTIMESTAMPTZ - TIMESTAMPTZ
AT TIME ZONE 'X'= "convert to zone X, return naive" → returns aTIMESTAMP
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.
'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.