Skip to content

Commit 97071b3

Browse files
authored
Merge pull request #4 from Priyans-hu/feat/heatmap-and-notifications
Usage heatmap and rate limit notifications
2 parents 024a894 + e9d120a commit 97071b3

File tree

9 files changed

+340
-53
lines changed

9 files changed

+340
-53
lines changed

README.md

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
11
# TokenMeter
22

3-
macOS menu bar app for visualizing Claude Code token usage and costs. Built with SwiftUI.
3+
macOS menu bar app for tracking Claude Code usage, rate limits, and costs. Built with SwiftUI.
44

5-
Parses Claude Code's local conversation data to show real-time usage stats — no API keys or external tools needed.
5+
Reads Claude Code's OAuth token from Keychain for real rate limit data, and parses local JSONL files for cost and usage analytics.
66

77
![macOS](https://img.shields.io/badge/macOS_14+-000?logo=apple&logoColor=white)
88
![SwiftUI](https://img.shields.io/badge/SwiftUI-007AFF?logo=swift&logoColor=white)
99

1010
## Features
1111

12-
- **Menu bar app** — lives in menu bar, no dock icon, works in fullscreen
13-
- **Native macOS** — built with SwiftUI and Swift Charts
14-
- **Rate limit tracking** — 5-hour session and weekly output token usage with plan-based limits
15-
- **Cost summary** — today / this week / this month (calculated with embedded pricing)
12+
- **Real rate limits** — fetches actual utilization from Anthropic API via Claude Code's OAuth token
13+
- **Rate limit notifications** — macOS alerts when session or weekly usage hits 80%
14+
- **Usage heatmap** — hour-by-day grid showing when you're most active (7/14/30 day range)
15+
- **Hover details** — token breakdown (input/output) appears on hover over rate limit bars
16+
- **Cost summary** — today / this week / this month (API-equivalent pricing)
1617
- **Daily cost chart** — bar chart with 7/14/30 day range
1718
- **Model breakdown** — donut chart showing per-model usage (Opus, Sonnet, Haiku)
18-
- **Plan selection**Pro / Max 5x / Max 20x for estimated rate limits
19+
- **Menu bar app**lives in menu bar, no dock icon, works in fullscreen
1920
- **Auto-refresh** — every 5 minutes (configurable)
20-
- **Zero dependencies**parses `~/.claude/` JSONL files directly, no external tools required
21+
- **Fallback mode**if Keychain/API unavailable, uses local JSONL estimates with plan selection
2122

2223
## Installation
2324

@@ -58,21 +59,26 @@ cp .build/release/TokenMeter /Applications/TokenMeter.app/Contents/MacOS/TokenMe
5859
### Prerequisites
5960

6061
- macOS 14 (Sonoma) or later
61-
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed and used (generates the local data TokenMeter reads)
62+
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed and used
6263

6364
## How It Works
6465

65-
TokenMeter reads Claude Code's local JSONL files from `~/.claude/projects/`:
66+
### Rate Limits (real data)
67+
TokenMeter reads Claude Code's OAuth token from the macOS Keychain and calls `api.anthropic.com/api/oauth/usage` to get real utilization percentages and reset times. On first launch, macOS will ask you to allow Keychain access — click "Always Allow".
6668

67-
1. **Daily usage** — scans all conversation JSONL files, groups by date, calculates token costs using embedded model pricing
68-
2. **Rate limits** — tracks output tokens in the last 5 hours (session) and 7 days (weekly), shows progress against estimated plan limits
69-
3. **Model breakdown** — identifies which models (Opus, Sonnet, Haiku) are being used and their relative costs
69+
### Usage Analytics (local data)
70+
Parses JSONL files from `~/.claude/projects/` and `~/.config/claude/projects/`:
71+
- **Daily costs** — groups by date, calculates using embedded model pricing
72+
- **Hourly heatmap** — groups by hour-of-day per date for activity patterns
73+
- **Model breakdown** — per-model token and cost breakdown
74+
- Deduplicates by `requestId` and filters `<synthetic>` entries
7075

71-
No API keys, no external tools, no cloud services. Everything is local.
76+
### Notifications
77+
Sends macOS notifications when rate limit utilization reaches 80% (session or weekly). Throttled to once per hour per window. Toggle in Settings.
7278

7379
### Pricing
7480

75-
Costs are calculated using Anthropic's published API-equivalent pricing (per million tokens):
81+
Costs are calculated using API-equivalent pricing (per million tokens):
7682

7783
| Model | Input | Output | Cache Write | Cache Read |
7884
|-------|-------|--------|-------------|------------|
@@ -88,28 +94,33 @@ Costs are calculated using Anthropic's published API-equivalent pricing (per mil
8894
```
8995
TokenMeter/
9096
├── TokenMeterApp.swift # App entry with MenuBarExtra
91-
├── UsageViewModel.swift # State management, timer, caching
97+
├── UsageViewModel.swift # State, timer, caching, notifications
9298
├── Models/
9399
│ └── UsageSummary.swift # Data models + ClaudePlan enum
94100
├── Services/
95101
│ ├── NativeUsageParser.swift # JSONL parser + pricing engine
102+
│ ├── UsageAPIService.swift # Keychain + Anthropic API client
96103
│ └── UpdateChecker.swift # GitHub releases checker
97104
└── Views/
98105
├── DashboardView.swift # Main popover container
99-
├── RateLimitView.swift # Progress bar with plan limits
106+
├── RateLimitView.swift # Progress bar with hover details
107+
├── UsageHeatmapView.swift # Hour-by-day activity heatmap
100108
├── CostSummaryView.swift # Today/week/month cost cards
101109
├── DailyChartView.swift # Swift Charts bar chart
102110
├── ModelBreakdownView.swift # Swift Charts donut chart
103-
└── SettingsView.swift # Plan picker, refresh interval
111+
└── SettingsView.swift # Plan, notifications, refresh interval
104112
```
105113

106114
```
107115
Data Flow:
108-
~/.claude/projects/*.jsonl ──> NativeUsageParser ──> DailyUsage + RateLimits
109-
116+
Keychain OAuth ──> UsageAPIService ──> Real rate limit %
117+
118+
~/.claude/*.jsonl ──> NativeUsageParser ──> Costs + Hourly + Tokens
119+
110120
Timer (5min) ──> UsageViewModel ──> UsageSummary ──> SwiftUI Views
111-
112-
└──> UserDefaults cache (instant reopen)
121+
│ │
122+
├──> UserDefaults cache └──> Notifications (≥80%)
123+
└──> UNUserNotificationCenter
113124
```
114125

115126
## License

TokenMeter/TokenMeter/Models/UsageSummary.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Foundation
22

33
struct UsageSummary: Codable {
44
let daily: [DailyUsage]
5+
let hourly: [HourlyUsage]
56
let todayCost: Double
67
let weekCost: Double
78
let monthCost: Double
@@ -68,6 +69,17 @@ struct WindowInfo: Codable {
6869
let windowHours: UInt32
6970
}
7071

72+
// MARK: - Hourly Usage (for heatmap)
73+
74+
struct HourlyUsage: Codable, Identifiable {
75+
var id: String { "\(date)-\(hour)" }
76+
77+
let date: String // yyyy-MM-dd
78+
let hour: Int // 0-23
79+
let outputTokens: UInt64
80+
let requestCount: Int
81+
}
82+
7183
// MARK: - Claude Plan
7284

7385
enum ClaudePlan: String, CaseIterable, Codable {
56.1 KB
Binary file not shown.

TokenMeter/TokenMeter/Services/NativeUsageParser.swift

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Foundation
22

33
struct ParseResult {
44
let daily: [DailyUsage]
5+
let hourly: [HourlyUsage]
56
let rateLimits: RateLimitInfo
67
}
78

@@ -30,9 +31,10 @@ struct NativeUsageParser {
3031
let entries = deduplicateByRequestId(rawEntries)
3132

3233
let daily = aggregateDailyUsage(entries: entries, since: cutoff)
34+
let hourly = aggregateHourlyUsage(entries: entries, since: cutoff)
3335
let rateLimits = computeRateLimits(entries: entries, now: now)
3436

35-
return ParseResult(daily: daily, rateLimits: rateLimits)
37+
return ParseResult(daily: daily, hourly: hourly, rateLimits: rateLimits)
3638
}
3739

3840
// MARK: - File Scanning
@@ -181,6 +183,36 @@ struct NativeUsageParser {
181183
}.sorted { $0.date < $1.date }
182184
}
183185

186+
// MARK: - Hourly Aggregation
187+
188+
private func aggregateHourlyUsage(entries: [ParsedEntry], since: Date) -> [HourlyUsage] {
189+
let dateFormatter = DateFormatter()
190+
dateFormatter.dateFormat = "yyyy-MM-dd"
191+
let calendar = Calendar.current
192+
193+
// key = "yyyy-MM-dd-HH"
194+
var hourMap: [String: (outputTokens: UInt64, requestCount: Int)] = [:]
195+
196+
for entry in entries {
197+
guard entry.timestamp >= since else { continue }
198+
let dateStr = dateFormatter.string(from: entry.timestamp)
199+
let hour = calendar.component(.hour, from: entry.timestamp)
200+
let key = "\(dateStr)-\(hour)"
201+
202+
var acc = hourMap[key] ?? (outputTokens: 0, requestCount: 0)
203+
acc.outputTokens += entry.outputTokens
204+
acc.requestCount += 1
205+
hourMap[key] = acc
206+
}
207+
208+
return hourMap.map { (key, acc) in
209+
let parts = key.split(separator: "-")
210+
let date = parts.prefix(3).joined(separator: "-")
211+
let hour = Int(parts.last!) ?? 0
212+
return HourlyUsage(date: date, hour: hour, outputTokens: acc.outputTokens, requestCount: acc.requestCount)
213+
}.sorted { $0.date == $1.date ? $0.hour < $1.hour : $0.date < $1.date }
214+
}
215+
184216
// MARK: - Rate Limits
185217

186218
private func computeRateLimits(entries: [ParsedEntry], now: Date) -> RateLimitInfo {

TokenMeter/TokenMeter/UsageViewModel.swift

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import Combine
3+
import UserNotifications
34

45
@MainActor
56
final class UsageViewModel: ObservableObject {
@@ -16,14 +17,22 @@ final class UsageViewModel: ObservableObject {
1617
private let apiService = UsageAPIService()
1718
private let updateChecker = UpdateChecker(currentVersion: Bundle.main.appVersion)
1819

20+
@Published var notificationsEnabled: Bool {
21+
didSet { UserDefaults.standard.set(notificationsEnabled, forKey: "notificationsEnabled") }
22+
}
23+
1924
private var refreshTimer: Timer?
2025
private var refreshInterval: TimeInterval = 300
26+
private var lastSessionNotification: Date?
27+
private var lastWeeklyNotification: Date?
2128

2229
init() {
2330
let planStr = UserDefaults.standard.string(forKey: "claudePlan") ?? "pro"
2431
selectedPlan = ClaudePlan(rawValue: planStr) ?? .pro
32+
notificationsEnabled = UserDefaults.standard.object(forKey: "notificationsEnabled") as? Bool ?? true
2533

2634
loadCachedData()
35+
requestNotificationPermission()
2736
Task {
2837
await refresh()
2938
await checkForUpdates()
@@ -47,9 +56,10 @@ final class UsageViewModel: ObservableObject {
4756
rateLimits.apiSession = apiUsage?.fiveHour
4857
rateLimits.apiWeekly = apiUsage?.sevenDay
4958

50-
let summary = aggregateSummary(daily: result.daily, rateLimits: rateLimits)
59+
let summary = aggregateSummary(daily: result.daily, hourly: result.hourly, rateLimits: rateLimits)
5160
self.summary = summary
5261
saveCachedData(summary)
62+
checkRateLimitAlerts(rateLimits)
5363

5464
isLoading = false
5565
}
@@ -64,7 +74,7 @@ final class UsageViewModel: ObservableObject {
6474

6575
// MARK: - Private
6676

67-
private func aggregateSummary(daily: [DailyUsage], rateLimits: RateLimitInfo) -> UsageSummary {
77+
private func aggregateSummary(daily: [DailyUsage], hourly: [HourlyUsage], rateLimits: RateLimitInfo) -> UsageSummary {
6878
let formatter = DateFormatter()
6979
formatter.dateFormat = "yyyy-MM-dd"
7080
let todayStr = formatter.string(from: Date())
@@ -96,6 +106,7 @@ final class UsageViewModel: ObservableObject {
96106

97107
return UsageSummary(
98108
daily: daily,
109+
hourly: hourly,
99110
todayCost: todayCost,
100111
weekCost: weekCost,
101112
monthCost: monthCost,
@@ -124,6 +135,52 @@ final class UsageViewModel: ObservableObject {
124135
guard let data = try? JSONEncoder().encode(summary) else { return }
125136
UserDefaults.standard.set(data, forKey: "cachedSummary")
126137
}
138+
139+
// MARK: - Notifications
140+
141+
private func requestNotificationPermission() {
142+
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
143+
}
144+
145+
private func checkRateLimitAlerts(_ rateLimits: RateLimitInfo) {
146+
guard notificationsEnabled else { return }
147+
let now = Date()
148+
149+
if let api = rateLimits.apiSession, api.utilization >= 80 {
150+
if lastSessionNotification == nil || now.timeIntervalSince(lastSessionNotification!) > 3600 {
151+
sendNotification(
152+
title: "Session limit at \(Int(api.utilization))%",
153+
body: "Your 5-hour rate limit is running low."
154+
)
155+
lastSessionNotification = now
156+
}
157+
}
158+
159+
if let api = rateLimits.apiWeekly, api.utilization >= 80 {
160+
if lastWeeklyNotification == nil || now.timeIntervalSince(lastWeeklyNotification!) > 3600 {
161+
sendNotification(
162+
title: "Weekly limit at \(Int(api.utilization))%",
163+
body: "Your 7-day rate limit is running low."
164+
)
165+
lastWeeklyNotification = now
166+
}
167+
}
168+
}
169+
170+
private func sendNotification(title: String, body: String) {
171+
let content = UNMutableNotificationContent()
172+
content.title = "TokenMeter"
173+
content.subtitle = title
174+
content.body = body
175+
content.sound = .default
176+
177+
let request = UNNotificationRequest(
178+
identifier: UUID().uuidString,
179+
content: content,
180+
trigger: nil
181+
)
182+
UNUserNotificationCenter.current().add(request)
183+
}
127184
}
128185

129186
// MARK: - Bundle Extension

TokenMeter/TokenMeter/Views/DashboardView.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ struct DashboardView: View {
4141
apiUtilization: summary.rateLimits.apiWeekly
4242
)
4343

44+
Divider()
45+
.padding(.vertical, 4)
46+
47+
// Usage Heatmap
48+
if !summary.hourly.isEmpty {
49+
UsageHeatmapView(hourly: summary.hourly)
50+
}
51+
4452
Divider()
4553
.padding(.vertical, 4)
4654

0 commit comments

Comments
 (0)