A complete modernization of karpathy/ulogme for macOS (2024+)
The original ulogme was built 11 years ago using Python 2.7, PyObjC, bash scripts, and vanilla JavaScript with D3.js. This rewrite targets modern macOS (Apple Silicon compatible), uses Python 3.13+ for the tracker daemon, and React/TypeScript for visualization. Our new rewrite will live in new/
| Layer | Technology | Notes |
|---|---|---|
| Package Manager | uv (Python), bun (TypeScript) | Modern, fast tooling |
| Database | DuckDB | Embedded analytics DB, excellent for time-series queries |
| Backend | Hono on Bun | Fast, minimal TypeScript server |
| Frontend | React + Vite + Tailwind | With shadcn/ui components |
| Charts | Recharts via shadcn/ui | See docs/shadcncharts.md for usage guide |
| Tracker | Python + PyObjC (pure) | Native macOS APIs, no pynput wrapper needed |
- Charting Guide:
site-template/docs/shadcncharts.md— Complete reference for building charts with shadcn/ui and Recharts
┌─────────────────────────────────────────────────────────────┐
│ Data Collection │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ ulogme_osx.py │ │ bash keyfreq │ │
│ │ (PyObjC/Python2) │ │ counter loop │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ logs/*.txt (timestamp + data per line) │ │
│ └────────────────────┬─────────────────────┘ │
│ │ │
│ ┌───────────┴───────────┐ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ export_events.py│───▶│ render/*.json │ │
│ └─────────────────┘ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ ulogme_serve.py │◀───│ index.html/overview │ │
│ │ (Python2 HTTP) │ │ (jQuery + D3.js) │ │
│ └─────────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Python Daemon (uv) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ tracker/ │ │
│ │ ├── daemon.py (main entry, launchd-friendly) │ │
│ │ ├── window.py (active window via pyobjc) │ │
│ │ ├── keyboard.py (key events via Quartz CGEvent) │ │
│ │ ├── storage.py (DuckDB for all events) │ │
│ │ └── config.py (user preferences) │ │
│ └────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ data/ulogme.duckdb (DuckDB) │ │
│ │ ├── window_events (timestamp, app_name, window_title) │ │
│ │ ├── key_events (timestamp, key_count) │ │
│ │ ├── notes (timestamp, content) │ │
│ │ └── daily_blog (date, content) │ │
│ └────────────────────────┬───────────────────────────────┘ │
└───────────────────────────┼─────────────────────────────────┘
│
┌───────────────────────────┼─────────────────────────────────┐
│ Hono/Bun Server (site-template/) │
│ │ │
│ ┌────────────────────────┴───────────────────────────────┐ │
│ │ server.ts │ │
│ │ └── API Routes (using @duckdb/node-api) │ │
│ │ GET /api/events/:date (day's events) │ │
│ │ GET /api/events (date range list) │ │
│ │ GET /api/overview (aggregated stats) │ │
│ │ POST /api/notes (add note) │ │
│ │ POST /api/blog (save blog) │ │
│ │ GET /api/settings (user prefs) │ │
│ │ PUT /api/settings (update prefs) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────┴───────────────────────────────┐ │
│ │ React Frontend (Vite + shadcn/ui + Recharts) │ │
│ │ ├── pages/ │ │
│ │ │ ├── DayView.tsx (single-day timeline) │ │
│ │ │ ├── Overview.tsx (multi-day stacked chart) │ │
│ │ │ └── Settings.tsx (configure mappings) │ │
│ │ └── components/ │ │
│ │ ├── TimelineChart.tsx │ │
│ │ ├── KeystrokeGraph.tsx │ │
│ │ ├── CategoryPieChart.tsx │ │
│ │ ├── NotesPanel.tsx │ │
│ │ └── HackingStreak.tsx │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
| Feature | Legacy Implementation | Modern Implementation |
|---|---|---|
| Active window tracking | PyObjC NSWorkspace notifications + AppleScript for Chrome tabs |
Same approach with pyobjc-framework-Cocoa + pyobjc-framework-Quartz |
| Keystroke counting | NSEvent.addGlobalMonitorForEventsMatchingMask_handler_ |
Same pure PyObjC approach (proven reliable) |
| Screen lock detection | NSWorkspaceScreensDidSleepNotification |
Same approach |
| Day boundary at 7am | rewind7am.py calculates "logical day" |
Same logic in Python module |
| Feature | Legacy (D3.js) | Modern (Recharts/React) |
|---|---|---|
| Timeline barcode | Custom SVG rects | <BarChart> with custom render |
| Keystroke frequency graph | D3 line chart | <AreaChart> |
| Pie chart (time distribution) | D3 pie | <PieChart> |
| Horizontal bar stats | Custom D3 bars | <BarChart layout="vertical"> |
| Hacking streak visualization | Custom intensity bars | Custom component with gradient |
| Notes markers | SVG text annotations | Interactive overlay component |
- Click on timeline bar → add note at that time
- Navigate between days (← →)
- Daily blog/memo entry
- Toggle categories on/off in overview
- Click day bar → navigate to day view
Goal: Reliable background data collection on modern macOS
cd new/
uv add duckdb pyobjc-framework-Cocoa pyobjc-framework-QuartzNote: We use pure PyObjC (no pynput). The original ulogme proved this approach works reliably, and pynput just wraps the same Quartz APIs anyway. Fewer dependencies = fewer potential issues.
new/
├── tracker/
│ ├── __init__.py
│ ├── daemon.py # Main entry point, signal handling
│ ├── window.py # Active window monitoring
│ ├── keyboard.py # Keystroke counting (privacy-respecting)
│ ├── storage.py # DuckDB operations
│ ├── config.py # Configuration loading
│ └── utils.py # Shared utilities (rewind7am, etc.)
├── data/ # Created at runtime
│ └── ulogme.duckdb
└── ulogme.toml # User configuration
Window Tracking (window.py):
# Core approach:
# 1. Subscribe to NSWorkspaceDidActivateApplicationNotification
# 2. On activation, get window name via Accessibility API or CGWindowListCopyWindowInfo
# 3. For browsers, get URL/tab title via AppleScript (enabled by default)
# 4. Write to DuckDB only when window changes (debounce rapid switches)Keystroke Counting (keyboard.py):
# Privacy-first design using pure PyObjC (same as legacy):
# - Only count keystrokes, never log actual keys
# - Use NSEvent.addGlobalMonitorForEventsMatchingMask_handler_ for key events
# - Aggregate counts in 9-second windows (matches legacy)
# - Requires Accessibility permission in System Settings
#
# Example (from legacy code):
# mask = NSKeyDownMask
# NSEvent.addGlobalMonitorForEventsMatchingMask_handler_(mask, self.key_handler)Storage Schema (storage.py):
Using DuckDB for its excellent analytics capabilities and time-series query performance:
-- DuckDB schema (native TIMESTAMP, aggregations, window functions)
CREATE TABLE window_events (
timestamp TIMESTAMP NOT NULL,
app_name VARCHAR NOT NULL,
window_title VARCHAR,
browser_url VARCHAR, -- Full URL for browsers (opt-in trackable)
logical_date DATE NOT NULL, -- 7am-based logical day
PRIMARY KEY (timestamp, app_name)
);
CREATE INDEX idx_window_logical_date ON window_events(logical_date);
CREATE TABLE key_events (
timestamp TIMESTAMP NOT NULL PRIMARY KEY,
key_count INTEGER NOT NULL,
logical_date DATE NOT NULL
);
CREATE INDEX idx_key_logical_date ON key_events(logical_date);
CREATE TABLE notes (
timestamp TIMESTAMP NOT NULL PRIMARY KEY,
content VARCHAR NOT NULL,
logical_date DATE NOT NULL
);
CREATE TABLE daily_blog (
logical_date DATE PRIMARY KEY,
content VARCHAR
);
CREATE TABLE settings (
key VARCHAR PRIMARY KEY,
value JSON -- DuckDB has native JSON support
);Why DuckDB?
- Embedded (single file, no server)
- Excellent for analytical queries (aggregations, window functions)
- Native
TIMESTAMPandDATEtypes with rich date/time functions - Native JSON support for settings
- Works great with both Python (
duckdbpackage) and TypeScript (@duckdb/node-api) - Columnar storage is efficient for time-series data
- Used for ALL data storage in this project (no mixed databases)
- macOS requires Accessibility permission for keystroke monitoring
- CLI commands:
uv run python -m tracker start— start daemonuv run python -m tracker stop— stop daemonuv run python -m tracker status— check if runninguv run python -m tracker install— install launchd serviceuv run python -m tracker uninstall— remove launchd service
Auto-start the tracker as a proper macOS user agent. The install command creates:
~/Library/LaunchAgents/com.ulogme.tracker.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.ulogme.tracker</string>
<key>ProgramArguments</key>
<array>
<string>/path/to/.local/bin/uv</string>
<string>run</string>
<string>--project</string>
<string>/path/to/ulogme/new</string>
<string>python</string>
<string>-m</string>
<string>tracker</string>
<string>run</string>
</array>
<key>WorkingDirectory</key>
<string>/path/to/ulogme/new</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/path/to/ulogme/new/data/tracker.log</string>
<key>StandardErrorPath</key>
<string>/path/to/ulogme/new/data/tracker.error.log</string>
</dict>
</plist>The install command will:
- Generate the plist with correct absolute paths
- Copy to
~/Library/LaunchAgents/ - Load with
launchctl load
The uninstall command will:
- Unload with
launchctl unload - Remove the plist file
Goal: REST API that reads DuckDB and serves data to frontend
cd site-template/
bun add @duckdb/node-apisite-template/
├── backend-lib/
│ ├── db.ts # DuckDB connection (replaces bun:sqlite)
│ └── utils.ts # Date utilities, rewind7am logic
├── server.ts # Add ulogme API routes
Note: All storage uses DuckDB. The existing
bun:sqlitecode will be replaced with DuckDB via@duckdb/node-api.
// GET /api/ulogme/dates
// Returns list of available dates
// Response: { dates: [{ logical_date: "2024-12-29", label: "Dec 29, 2024" }, ...] }
// GET /api/ulogme/day/:logical_date
// Returns all events for a single day
// Response: {
// window_events: [{ timestamp: "2024-12-29T10:00:00", app: "Chrome", title: "GitHub", url: "https://github.com/..." }, ...],
// key_events: [{ timestamp: "2024-12-29T10:00:09", count: 42 }, ...],
// notes: [{ timestamp: "2024-12-29T12:00:00", content: "lunch break" }, ...],
// blog: "Productive day working on..."
// }
// GET /api/ulogme/overview?from=X&to=Y
// Aggregated stats across date range (DuckDB excels at these!)
// Response: {
// days: [{ logical_date, total_keys, category_durations: {...} }, ...],
// totals: { total_keys, total_time, by_category: {...} }
// }
// POST /api/ulogme/note
// Body: { timestamp: number, content: string }
// PUT /api/ulogme/blog/:logical_date
// Body: { content: string }
// GET /api/ulogme/settings
// PUT /api/ulogme/settings
// Body: { title_mappings: [...], display_groups: [...], hacking_titles: [...] }import { DuckDBInstance } from "@duckdb/node-api";
// Initialize DuckDB connection
const DB_PATH = "../data/ulogme.duckdb";
// Create instance and connection
const instance = await DuckDBInstance.create(DB_PATH);
const connection = await instance.connect();
// Example: Query day's events with duration calculation using native TIMESTAMP
const result = await connection.run(`
SELECT
app_name,
COUNT(*) as event_count,
SUM(LEAD(timestamp) OVER (ORDER BY timestamp) - timestamp) as total_duration
FROM window_events
WHERE logical_date = ?
GROUP BY app_name
ORDER BY total_duration DESC
`, [logicalDate]);
// DuckDB's analytical capabilities shine for overview queries:
const overview = await connection.run(`
SELECT
logical_date,
SUM(key_count) as total_keys,
COUNT(DISTINCT app_name) as unique_apps
FROM key_events k
JOIN window_events w USING (logical_date)
WHERE logical_date BETWEEN ? AND ?
GROUP BY logical_date
ORDER BY logical_date
`, [fromDate, toDate]);
// Date functions work naturally with native TIMESTAMP/DATE types:
const today = await connection.run(`
SELECT * FROM window_events
WHERE logical_date = CURRENT_DATE
ORDER BY timestamp
`);Goal: Beautiful, modern visualization of activity data
📚 Chart Reference: See
docs/shadcncharts.mdfor complete Recharts usage with shadcn/ui
src/pages/
├── DayView.tsx # Single-day detailed view
├── Overview.tsx # Multi-day aggregated view
└── Settings.tsx # Configure title mappings
src/components/ulogme/
├── ActivityTimeline.tsx # Main barcode-style timeline
├── KeystrokeChart.tsx # Area chart of typing activity
├── CategoryPieChart.tsx # Time distribution pie
├── CategoryStats.tsx # Horizontal bar chart of categories
├── HackingStreak.tsx # "Focused work" visualization
├── NotesOverlay.tsx # Notes markers on timeline
├── DayNavigation.tsx # ← Day navigation →
├── BlogEntry.tsx # Daily blog/memo editor
├── OverviewChart.tsx # Stacked bar chart for overview
└── CategoryLegend.tsx # Interactive legend (click to toggle)
Color Palette (CRITICAL):
Per docs/shadcncharts.md, use CSS variables correctly:
// ✅ CORRECT: Use var(--chart-N) directly
const chartConfig = {
browser: { label: "Browser", color: "var(--chart-1)" },
coding: { label: "Coding", color: "var(--chart-2)" },
terminal: { label: "Terminal", color: "var(--chart-3)" },
} satisfies ChartConfig;
// Then in components: var(--color-<dataKey>)
<Bar dataKey="browser" fill="var(--color-browser)" />
// ❌ WRONG: Don't wrap in hsl() - causes invisible charts!
// color: "hsl(var(--chart-1))" // BROKENTimeline Component Spec:
// Each category gets its own row (like legacy "barcode view")
// Hover shows tooltip with window title + duration
// Click opens note dialog for that timestamp
// Time axis shows hours (7am start matches logical day)1. KeystrokeChart — Area chart with gradient fill:
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
const chartConfig = {
keystrokes: { label: "Keystrokes", color: "var(--chart-1)" },
} satisfies ChartConfig;
<ChartContainer config={chartConfig} className="h-[150px] w-full">
<AreaChart data={keyEvents} accessibilityLayer>
<defs>
<linearGradient id="fillKeys" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-keystrokes)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--color-keystrokes)" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis dataKey="time" tickFormatter={formatHour} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area dataKey="count" fill="url(#fillKeys)" stroke="var(--color-keystrokes)" />
</AreaChart>
</ChartContainer>2. CategoryPieChart — Donut with center label:
import { Pie, PieChart, Label } from "recharts";
// Data must include `fill` property for each slice
const pieData = categories.map(cat => ({
name: cat.name,
value: cat.duration,
fill: `var(--color-${cat.key})`,
}));
<PieChart>
<Pie data={pieData} dataKey="value" innerRadius={60}>
<Label content={({ viewBox }) => (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle">
<tspan className="fill-foreground text-2xl font-bold">
{formatDuration(totalTime)}
</tspan>
</text>
)} />
</Pie>
</PieChart>3. CategoryStats — Horizontal bars:
import { Bar, BarChart, XAxis, YAxis } from "recharts";
<BarChart data={categoryStats} layout="vertical">
<XAxis type="number" hide />
<YAxis dataKey="name" type="category" tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="duration" radius={4}>
{categoryStats.map((entry) => (
<Cell key={entry.name} fill={`var(--color-${entry.key})`} />
))}
</Bar>
</BarChart>4. OverviewChart — Stacked vertical bars (one per day):
<BarChart data={dailyData} accessibilityLayer>
<CartesianGrid vertical={false} />
<XAxis dataKey="date" tickFormatter={formatDate} />
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend content={<ChartLegendContent />} />
{categories.map((cat, i) => (
<Bar key={cat} dataKey={cat} stackId="a" fill={`var(--chart-${(i % 5) + 1})`} />
))}
</BarChart>- React Query (or
useSWR) for data fetching with caching - URL-based routing for day navigation (
/day/:date,/overview) - Local storage for user preferences (synced with backend settings)
Goal: Flexible, user-configurable window title → category mapping
// Pattern matching with ordered rules
var title_mappings = [
{pattern: /Google Chrome/, mapto: 'Browser'},
{pattern: /\.py.*VS Code/, mapto: 'Coding'}, // Order matters!
{pattern: /VS Code/, mapto: 'Editor'},
];// Settings stored in DuckDB (JSON), editable via UI
interface TitleMapping {
pattern: string; // Regex pattern
category: string; // Target category name
priority: number; // Higher = checked first
}
interface CategoryGroup {
name: string;
categories: string[]; // Categories to render together
color?: string; // Optional override
}
interface UlogmeSettings {
title_mappings: TitleMapping[];
display_groups: CategoryGroup[];
hacking_categories: string[]; // For "focused work" detection
day_boundary_hour: number; // Default 7
}- Drag-and-drop reordering of mapping rules
- Live preview of pattern matching
- Preset templates for common apps
- Import/export settings as JSON
- Permission check (Accessibility for keystrokes)
- Initial category mapping wizard
- Start tracker daemon
- First data collection confirmation
- Polling or WebSocket for live dashboard updates
- Debounced refresh (don't hammer DB)
- "Last synced: X seconds ago" indicator
- DuckDB's columnar storage is optimized for analytical queries
- Leverage DuckDB window functions for duration calculations
- Pagination for overview (don't load 365 days at once)
- Virtualized lists for long note histories
- DuckDB handles aggregations efficiently — no need for pre-computed rollups
- Set up dependencies via uv (
duckdb,pyobjc-framework-Cocoa,pyobjc-framework-Quartz) - Implement
utils.pywith rewind7am logic (using native datetime/DATE) - Implement
storage.pywith DuckDB schema and queries (TIMESTAMP/DATE types) - Implement
window.pywith NSWorkspace monitoring + CGWindowListCopyWindowInfo - Implement
keyboard.pywith NSEvent global monitor (pure PyObjC) - Implement
daemon.pywith proper signal handling - Create
config.pyfor loading ulogme.toml - Test on modern macOS (Sonoma/Sequoia)
- Add permission check/request helpers
- Create CLI for start/stop/status/install/uninstall
- Implement launchd plist generation and installation
- Add
@duckdb/node-apidependency to site-template - Replace
bun:sqliteindb.tswith DuckDB connection - Implement
/api/ulogme/datesendpoint - Implement
/api/ulogme/day/:dateendpoint - Implement
/api/ulogme/overviewendpoint (leverage DuckDB aggregations) - Implement note and blog POST endpoints
- Implement settings GET/PUT endpoints
- Add category mapping logic (regex eval)
- Create
DayView.tsxpage with routing - Build
ActivityTimeline.tsxcomponent - Build
KeystrokeChart.tsxcomponent - Build
CategoryPieChart.tsxcomponent - Build
CategoryStats.tsxcomponent - Build
HackingStreak.tsxcomponent - Build
NotesOverlay.tsxcomponent - Build
DayNavigation.tsxcomponent - Build
BlogEntry.tsxcomponent - Wire up data fetching and state
- Create
Overview.tsxpage - Build
OverviewChart.tsx(stacked bars) - Build
CategoryLegend.tsx(toggleable) - Build summary stats components
- Add date range picker
- Create
Settings.tsxpage - Build title mapping editor
- Build display groups editor
- Build hacking categories selector
- Add import/export functionality
- End-to-end testing
- Error handling and loading states
- Responsive design check
- Dark mode testing
- Performance optimization
- Documentation
new/
├── pyproject.toml # Python project config (uv managed)
├── ulogme.toml # User configuration
├── REWRITE_PLAN.md # This file
├── tracker/
│ ├── __init__.py
│ ├── __main__.py # CLI entry point
│ ├── daemon.py
│ ├── window.py
│ ├── keyboard.py
│ ├── storage.py # DuckDB operations
│ ├── config.py
│ ├── launchd.py # launchd plist generation & installation
│ └── utils.py
├── data/
│ ├── ulogme.duckdb # DuckDB database file
│ ├── tracker.log # stdout from launchd
│ └── tracker.error.log # stderr from launchd
└── site-template/
├── package.json # Includes @duckdb/node-api
├── server.ts # Hono server + API
├── backend-lib/
│ ├── db.ts # DuckDB connection & queries
│ └── utils.ts
├── docs/
│ └── shadcncharts.md # Chart implementation guide
└── src/
├── pages/
│ ├── DayView.tsx
│ ├── Overview.tsx
│ └── Settings.tsx
└── components/
└── ulogme/
├── ActivityTimeline.tsx
├── KeystrokeChart.tsx
├── CategoryPieChart.tsx
├── CategoryStats.tsx
├── HackingStreak.tsx
├── NotesOverlay.tsx
├── DayNavigation.tsx
├── BlogEntry.tsx
├── OverviewChart.tsx
└── CategoryLegend.tsx
# Start the tracker daemon (foreground)
cd new/
uv run python -m tracker start
# Stop the tracker
uv run python -m tracker stop
# Check status
uv run python -m tracker status
# Install as launchd service (auto-start on login)
uv run python -m tracker install
# Uninstall launchd service
uv run python -m tracker uninstall
# Start the web UI (development)
cd new/site-template/
bun run dev
# Build for production
bun run build
bun run prod- Keystroke counting only — We never log actual keypress characters
- Local storage only — All data stays on your machine (DuckDB file)
- No network calls — Tracker daemon is fully offline
- All tracking enabled by default — This is a personal tool for your own machine
- Accessibility permission — Required for global key monitoring, user must grant
Default Configuration (ulogme.toml):
[tracking]
# All tracking enabled by default — this is your personal data on your local machine
window_titles = true
browser_tabs = true # Track active tab titles
browser_urls = true # Track full URLs (useful for categorization)
keystrokes = true
[day_boundary]
hour = 7 # Day starts at 7am (late night sessions count as previous day)- Start with Phase 1 — Get the tracker daemon working and collecting data
- Then Phase 2 — Build API to serve that data
- Then Phase 3 — Build the day view (most important visualization)
- Iterate — Add overview, settings, polish
The tracker is the foundation — without data collection, there's nothing to visualize. Get that solid first, then build up the UI layer by layer.