Local-first attention analytics for macOS, Safari, and Chrome.
Turn private ActivityWatch events into an explainable, portfolio-ready dashboard—without publishing raw URLs, window titles, messages, or documents.
Most screen-time tools answer how long was the laptop used? They do not answer the product questions behind digital attention:
- How often did I switch applications, windows, and browser contexts?
- Was active time concentrated in focused work or fragmented across many surfaces?
- What categories were selected browser tabs about?
- How reliable is the underlying tracking signal?
- Can the analysis remain useful without sending a detailed behavioral history to the cloud?
Pulse began as a native Swift experiment and evolved into an analytics layer on top of ActivityWatch, an open-source, local-first activity tracker. The project demonstrates a practical build-versus-integrate decision: reuse mature collection infrastructure, then invest engineering effort in reconciliation, privacy, categorization, and product-grade presentation.
ActivityWatch already provides foreground-window and AFK watchers. Pulse adds:
- AFK-aware interval reconciliation so foreground time is not inflated;
- context-switch metrics across applications and window contexts;
- Safari and Chrome enrichment through the ActivityWatch web watcher;
- explainable browser categorization using deterministic local rules;
- data-quality checks that prevent browser heartbeats from being double-counted as screen time;
- privacy-safe export with no raw URLs, page titles, window titles, or message/document names;
- a responsive React dashboard that runs locally and deploys to GitHub Pages;
- an optional native macOS prototype for sleep, wake, lock, app-lifecycle, and clamshell research.
flowchart LR
subgraph macOS[macOS collection]
W[Window watcher]
A[AFK watcher]
S[Optional Swift system monitor]
end
subgraph browser[Browser enrichment]
C[Chrome extension]
F[Safari Web Extension]
end
W --> DB[(Local ActivityWatch SQLite)]
A --> DB
C --> DB
F --> DB
DB --> E[Read-only Python exporter]
E --> R[Interval reconciliation]
R --> P[Privacy + categorization]
P --> J[Aggregate JSON]
J --> UI[React dashboard]
S -. product prototype .-> L[(Local JSONL)]
The React application never receives the ActivityWatch database. It reads a small aggregate JSON contract. Browser category time stays separate from foreground application time unless the underlying sources can be reconciled.
See Architecture and metric contract for calculation details.
If you are reviewing this project, start with the hosted dashboard rather than installing the collector. It opens a sanitized, read-only snapshot of the latest aggregate available in this repository (currently eight days, Aug 15–22). It contains no raw URLs, page titles, window titles, messages, or documents.
- Quick demo: Pulse Demo on ChatGPT Sites
- Source and setup: continue with the repository instructions below.
The demo is intentionally static: it shows the product experience and metric definitions without asking a visitor to grant macOS permissions or run ActivityWatch. A local installation can replace the demo JSON with a private export from the user's own Mac.
Treat a green build as necessary but not sufficient. Before sharing a new
deployment, run npm test, open the published URL, confirm the page returns
successfully, and verify that the visible dashboard content and sanitized demo
dataset load. If the hosted route is blank or returns 404, do not share the
URL—check the deployment logs and redeploy only after the page is verified.
The default GitHub build uses a sanitized aggregate snapshot. A local export automatically replaces it during development.
Dashboard sections include:
- active time, focus share, longest block, and switch rate;
- daily active-versus-focused trends;
- foreground application mix;
- separate Safari and Chrome category distributions;
- open-tab, observed-URL, and selected-tab-change signals;
- locked time, AFK transitions, and application switching;
- data freshness, privacy status, and source-reconciliation warnings.
Browser metrics describe selected-tab signals that overlap non-AFK activity. They are not added to total foreground time, and they are not a complete inventory of untouched background tabs.
Requirements: macOS, Python 3.11+, Node.js 20+, and ActivityWatch.
npm --prefix dashboard installWith ActivityWatch running:
npm run collectThis reads:
~/Library/Application Support/activitywatch/aw-server-rust/sqlite.db
and writes:
dashboard/local-data/activity-summary.local.json
The generated file is ignored by Git.
To schedule the privacy-safe export every Monday at 7:00 PM local time on macOS:
npm run schedule:weeklyThis installs a per-user launchd job at
~/Library/LaunchAgents/com.pulse.activity-export.plist. It reads ActivityWatch
locally and refreshes only the ignored aggregate JSON. Disable it with:
npm run unschedule:weeklyPulse reads the aggregate when the dashboard page opens; reload the page after a scheduled export to see the new data.
To use another database or output path:
python3 scripts/export_activitywatch.py \
--db "/path/to/sqlite.db" \
--output "/path/to/summary.json"npm run devOpen the local URL printed by Vite. If no local aggregate exists, the application falls back to the committed sanitized demo.
npm run buildThe static output is created in dashboard/dist/ and is compatible with GitHub Pages.
- Download the current macOS release from the ActivityWatch getting-started guide.
- Move the application to
/Applicationsand launch it. - Add ActivityWatch to System Settings → General → Login Items if the release does not configure autostart.
- Confirm these local buckets exist in ActivityWatch:
aw-watcher-window-*aw-watcher-afk-*
ActivityWatch's bundled watchers track the foreground window and AFK state; its data remains stored locally by default. The upstream project and watcher model are documented in the ActivityWatch repository.
Allow ActivityWatch under System Settings → Privacy & Security → Accessibility if window context is missing. This permission allows the window watcher to read the active application/window metadata; it does not give Pulse page contents or keystrokes.
Use the official ActivityWatch Web Watcher in the Chrome Web Store.
- Install the extension.
- Keep ActivityWatch running locally.
- Open
chrome://extensions, select ActivityWatch Web Watcher, and verify it is enabled. - Leave Allow in Incognito disabled.
- Open a normal tab and confirm an
aw-watcher-web-chrome-*bucket appears in ActivityWatch.
The watcher records current-tab metadata—not page contents—and sends events to the local ActivityWatch server.
Safari does not currently have the same one-click store path, so this project uses the upstream ActivityWatch browser-watcher source and Apple's Safari Web Extension converter.
Requirements: full Xcode, an Apple development signing identity, Node.js, Git, and Make.
git clone --recurse-submodules https://github.com/activitywatch/aw-watcher-web.git
cd aw-watcher-web
make install
make build-safari
xcrun safari-web-extension-converter ./buildThen:
- In Xcode, select the macOS target, sign it, build, and run it.
- Open Safari → Settings → Extensions and enable
aw-watcher-web. - Keep access to Private Browsing disabled.
- Confirm an
aw-watcher-web-safari-*bucket appears in ActivityWatch.
Safari Web Extensions use a generated safari-web-extension://… origin. If ActivityWatch rejects the request, add the exact extension origin to the Rust server configuration:
# ~/Library/Application Support/activitywatch/aw-server-rust/config.toml
cors_regex = ["^safari-web-extension://<your-extension-uuid>$"]Restart ActivityWatch after editing the file. Keep this rule narrow: allow the generated Safari extension origin, not arbitrary origins.
| Component | Permission/data | Purpose |
|---|---|---|
| ActivityWatch window watcher | macOS Accessibility | Active app and window context |
| ActivityWatch AFK watcher | Local input-idle signal | Exclude inactive time |
| Chrome web watcher | tabs, local ActivityWatch host |
Selected-tab title/URL metadata |
| Safari web watcher | Safari extension access, local ActivityWatch origin | Selected-tab title/URL metadata |
| Pulse exporter | Read-only access to local SQLite file | Produce aggregate dashboard JSON |
| React dashboard | Aggregate JSON only | Visualize sanitized metrics |
No component in this repository captures screenshots, keystrokes, message contents, or page bodies.
Raw behavioral data is highly sensitive. The exporter therefore omits:
- URLs, domains, search queries, and page titles;
- window, document, and conversation titles;
- email addresses and message recipients;
- browser tab IDs and raw event payloads.
The repository ignores ActivityWatch databases, SQLite sidecars, JSONL logs, local dashboard exports, and the local Safari build checkout. Read the full privacy model before making a fork public.
| Metric | Definition |
|---|---|
| Active time | Foreground-window duration intersected with not-afk intervals, excluding loginwindow |
| Application switch | Change in consecutive active foreground application names |
| Context switch | Change in consecutive active (application, window title) contexts |
| Focused work | Active time in the explainable AI/engineering application categories |
| Browser category share | Current-tab event duration overlapping any non-AFK interval, grouped locally |
| Distinct observed URL | Unique URL after fragment removal; not a count of current tabs |
| Locked time | Active-overlap time attributed to loginwindow, shown separately |
The native Swift prototype proved that macOS exposes many useful system signals. ActivityWatch was selected as the primary event backbone because it already offered mature AFK/window watchers, local storage, APIs, and a browser ecosystem. Engineering effort shifted toward differentiated product value rather than rebuilding a watcher platform.
Browser categorization uses deterministic URL/title rules. Rules are easy to inspect, test, and customize, and the public export contains only the resulting category. A future local model could handle unmatched sites, but should not require sending browsing history to a hosted API.
Browser watchers may keep a current-tab heartbeat alive while another application is foreground. Pulse surfaces that disagreement and keeps browser metrics separate instead of presenting false precision or double-counted totals.
GitHub Pages deploys the sanitized demo. Local development automatically prefers the ignored local aggregate. This creates a safe default while preserving a real working pipeline.
# Python interval and categorization tests + React production build
npm run test
# Optional native macOS collector tests
npm run test:swiftGitHub Actions validates the Python analytics layer, TypeScript production build, and Swift package. A separate workflow deploys dashboard/dist to GitHub Pages.
.
├── dashboard/ React + TypeScript dashboard
│ ├── public/data/ sanitized public demo
│ ├── local-data/ ignored local aggregate, dev-only
│ └── src/ components, charts, and data contract
├── scripts/
│ ├── export_activitywatch.py privacy-safe ActivityWatch exporter
│ └── package-app.sh native macOS app bundler
├── Sources/AttentionTracker/ optional Swift 6 collector prototype
├── BrowserExtension/ optional localhost tab-lifecycle bridge
├── tests/ Python interval/categorization tests
├── Tests/ Swift summary tests
└── docs/ architecture, metrics, and privacy
See docs/PRD.md for the product brief, user problem, goals,
metric definitions, privacy requirements, roadmap, risks, and release checklist.
After pushing to GitHub:
- Open Repository Settings → Pages.
- Set the source to GitHub Actions.
- Run Deploy dashboard to GitHub Pages, or push a dashboard change to
main.
The deployment contains only the sanitized demo unless you intentionally replace it. Do not commit activity-summary.local.json.
- configurable category rules and a safe local review UI;
- weekly trend baselines and change detection;
- debounced context-switch metrics and focus-session distributions;
- optional launch-at-login packaging for the native collector;
- lid/sleep/session events merged into the aggregate contract;
- local-only tab inventory for untouched-tab cleanup;
- encrypted local retention controls;
- one-command release packaging.
Suggested resume bullet:
Built a local-first attention analytics product on ActivityWatch using Python, SQLite interval reconciliation, Swift/macOS system signals, Chrome/Safari extensions, and a React dashboard; designed a privacy-safe aggregation layer that categorizes browser behavior without publishing raw URLs or window titles.
The product-management story is broader than the stack: identify an unmet user need, prototype the hard platform constraints, choose where to integrate versus build, define trustworthy metrics, expose data-quality limitations, and ship a usable privacy-first experience.
Pulse is an independent project built on top of ActivityWatch and is not affiliated with or endorsed by the ActivityWatch maintainers. ActivityWatch and aw-watcher-web are distributed under their upstream licenses. This repository's original Pulse code is available under the MIT License.