Skip to content

Commit ecdf296

Browse files
authored
feat: per-project token usage, merge Insights into Activity, 24H chart (#113)
* feat: add per-project token usage section between Tokens and Activity Groups JSONL entries by working directory (cwd) to show which projects consume the most tokens and estimated cost. Uses full cwd path as grouping key to avoid collisions between identically-named directories. * fix: correct id field comment and spec docs to reflect full cwd path key The id field uses the full cwd path (not lastPathComponent) after the path collision fix. Updates the struct comment and DATA_LAYER.md to match. * feat: polish token sections — better wording, compact cost, 6-project limit with expand/search/sort - Rename "Tokens" → "Token Usage" for clarity - Prefix all cost values with "~" to indicate API-equivalent estimate - Add formatCompactCost that drops cents for >= $1 (e.g. "$18") - Projects section: show top 6, "Show all (N)" expand toggle, search filter when expanded, sort toggle (by tokens / by cost) - Widen Token Usage cost column to 54pt for "~" prefix - Project cost uses tertiaryLabel for visual separation from token values * feat: merge Insights into Activity section, expand hourly chart to 24H - Change hourly chart from 12-hour to 24-hour trailing window - Move All Time, Longest session, and Period rows into Activity section - Delete InsightsSection.swift (functionality merged into ActivityChartView) - Add sort/search/expand controls to ProjectUsageSection - Remove orphaned insightsCollapsed UserDefaults key - Update all spec files and README test coverage * fix: remove stale insights comment and force unwrap in ProjectUsageSection - Comment referenced deleted "insights" section, updated to "insight rows" - Replace force unwrap in ProjectSortMode.next with guard-let fallback --------- Co-authored-by: KyleNesium <22541778+KyleNesium@users.noreply.github.com>
1 parent 42639a1 commit ecdf296

20 files changed

+724
-201
lines changed

AIBattery/Models/ModelPricing.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@ struct ModelPricing {
2929
return String(format: "$%.2f", cost)
3030
}
3131

32+
/// Compact cost format — drops cents for >= $1 (e.g. "$18"), keeps precision for small amounts.
33+
static func formatCompactCost(_ cost: Double) -> String {
34+
if cost == 0 {
35+
return "$0"
36+
}
37+
if cost < 0.01 {
38+
return "<$0.01"
39+
}
40+
if cost < 1 {
41+
return String(format: "$%.2f", cost)
42+
}
43+
if cost < 100 {
44+
return String(format: "$%.0f", cost)
45+
}
46+
return String(format: "$%.0f", cost)
47+
}
48+
3249
/// Lookup cache — avoids repeated displayName + linear scan per model ID.
3350
/// Lock-protected for thread safety (Swift Testing runs tests concurrently).
3451
/// Uses `Optional<ModelPricing>` values; key presence means "already looked up",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Foundation
2+
3+
struct ProjectTokenSummary: Identifiable {
4+
let id: String // full cwd path (unique key; "Other" for nil cwd)
5+
let projectName: String
6+
let inputTokens: Int
7+
let outputTokens: Int
8+
let cacheReadTokens: Int
9+
let cacheWriteTokens: Int
10+
let estimatedCost: Double // pre-computed from per-entry model pricing
11+
12+
var totalTokens: Int {
13+
inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens
14+
}
15+
}

AIBattery/Models/UsageSnapshot.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ struct UsageSnapshot {
2626
// Token breakdown per model
2727
let modelTokens: [ModelTokenSummary]
2828

29+
// Token breakdown per project (from JSONL cwd field)
30+
let projectTokens: [ProjectTokenSummary]
31+
2932
// Total tokens (pre-computed at construction to avoid per-render reduce)
3033
let totalTokens: Int
3134

AIBattery/Services/UsageAggregator.swift

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ final class UsageAggregator {
125125
}
126126

127127
let rawModelTokens = Self.buildModelTokens(from: modelTokensMap)
128+
let projectTokens = Self.buildProjectTokens(from: allEntries)
128129

129130
// Merge with persistent ledger — preserves high-water marks across stats-cache rebuilds.
130131
let modelTokens: [ModelTokenSummary]
@@ -224,6 +225,7 @@ final class UsageAggregator {
224225
todaySessions: todaySessions,
225226
todayToolCalls: todayToolCalls,
226227
modelTokens: modelTokens,
228+
projectTokens: projectTokens,
227229
totalTokens: modelTokens.reduce(0) { $0 + $1.totalTokens },
228230
dailyActivity: activity,
229231
dailyAverage: activityStats.average,
@@ -245,6 +247,61 @@ final class UsageAggregator {
245247
return snapshot
246248
}
247249

250+
/// Group JSONL entries by project (full cwd path as key), compute per-entry cost.
251+
/// Entries without cwd are grouped under "Other". JSONL-only — stats-cache lacks per-entry cwd.
252+
private static func buildProjectTokens(from entries: [AssistantUsageEntry]) -> [ProjectTokenSummary] {
253+
struct Accumulator {
254+
var displayName: String
255+
var input: Int = 0
256+
var output: Int = 0
257+
var cacheRead: Int = 0
258+
var cacheWrite: Int = 0
259+
var cost: Double = 0
260+
}
261+
262+
var byProject: [String: Accumulator] = [:]
263+
for entry in entries {
264+
guard entry.model.hasPrefix("claude-") else { continue }
265+
266+
let key: String
267+
let displayName: String
268+
if let cwd = entry.cwd, !cwd.isEmpty {
269+
key = cwd
270+
displayName = URL(fileURLWithPath: cwd).lastPathComponent
271+
} else {
272+
key = "Other"
273+
displayName = "Other"
274+
}
275+
276+
var acc = byProject[key] ?? Accumulator(displayName: displayName)
277+
acc.input += entry.inputTokens
278+
acc.output += entry.outputTokens
279+
acc.cacheRead += entry.cacheReadTokens
280+
acc.cacheWrite += entry.cacheWriteTokens
281+
if let pricing = ModelPricing.pricing(for: entry.model) {
282+
acc.cost += pricing.cost(
283+
input: entry.inputTokens,
284+
output: entry.outputTokens,
285+
cacheRead: entry.cacheReadTokens,
286+
cacheWrite: entry.cacheWriteTokens
287+
)
288+
}
289+
byProject[key] = acc
290+
}
291+
292+
return byProject.map { key, acc in
293+
ProjectTokenSummary(
294+
id: key,
295+
projectName: acc.displayName,
296+
inputTokens: acc.input,
297+
outputTokens: acc.output,
298+
cacheReadTokens: acc.cacheRead,
299+
cacheWriteTokens: acc.cacheWrite,
300+
estimatedCost: acc.cost
301+
)
302+
}.sorted { $0.totalTokens > $1.totalTokens }
303+
}
304+
248305
/// Filter to Claude models, map to summaries, sort by total tokens descending.
249306
private static func buildModelTokens(
250307
from map: [String: (input: Int, output: Int, cacheRead: Int, cacheWrite: Int)]

AIBattery/Utilities/UserDefaultsKeys.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ enum UserDefaultsKeys {
2323
static let throttleTimestamps = "aibattery_throttleTimestamps"
2424
static let contextCollapsed = "aibattery_contextCollapsed"
2525
static let tokensCollapsed = "aibattery_tokensCollapsed"
26+
static let projectsCollapsed = "aibattery_projectsCollapsed"
2627
static let activityCollapsed = "aibattery_activityCollapsed"
27-
static let insightsCollapsed = "aibattery_insightsCollapsed"
2828
/// Prefix for per-account token expiry timestamps (append account ID).
2929
static let tokenExpiresAtPrefix = "aibattery_expiresAt_"
3030
}

AIBattery/Views/ActivityChartData.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ enum ActivityChartData {
4343
}
4444
}
4545

46-
// MARK: - Hourly (12H)
46+
// MARK: - Hourly (24H)
4747

48-
/// Generates 12 hourly data points for the trailing 12-hour window.
48+
/// Generates 24 hourly data points for the trailing 24-hour window.
4949
static func hourlyData(from hourCounts: [String: Int], now: Date = .now) -> [HourlyPoint] {
5050
let currentHour = Calendar.current.component(.hour, from: now)
51-
return (0..<12).map { offset in
52-
let hour = (currentHour - 11 + offset + 24) % 24
51+
return (0..<24).map { offset in
52+
let hour = (currentHour - 23 + offset + 24) % 24
5353
return HourlyPoint(offset: offset, hour: hour, count: hourCounts[String(hour)] ?? 0)
5454
}
5555
}

AIBattery/Views/ActivityChartView.swift

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Charts
44
// MARK: - Chart Mode
55

66
enum ActivityChartMode: String, CaseIterable {
7-
case hourly = "12H"
7+
case hourly = "24H"
88
case daily = "7D"
99
case monthly = "12M"
1010
}
@@ -105,7 +105,7 @@ struct ActivityChartView: View {
105105
.frame(width: 120)
106106
.scaleEffect(0.8, anchor: .trailing)
107107
.accessibilityLabel("Activity time range")
108-
.accessibilityHint("Switch between 12 hour, 7 day, and 12 month views")
108+
.accessibilityHint("Switch between 24 hour, 7 day, and 12 month views")
109109
.help("Switch activity chart time range")
110110
}
111111
}
@@ -134,10 +134,11 @@ struct ActivityChartView: View {
134134
}
135135
}
136136

137-
// Trend summary
137+
// Trend summary + insight rows
138138
if !collapsed, let snapshot {
139139
trendSummary(snapshot)
140140
.accessibilityElement(children: .combine)
141+
insightRows(snapshot)
141142
}
142143
}
143144
.padding(.horizontal, 16)
@@ -254,17 +255,17 @@ struct ActivityChartView: View {
254255
.accessibilityLabel(a11yLabel)
255256
}
256257

257-
// MARK: - Hourly Chart (12H)
258+
// MARK: - Hourly Chart (24H)
258259

259260
private var hourlyChart: some View {
260261
let data = cachedHourly
261262
let total = data.reduce(0) { $0 + $1.count }
262263
let peak = data.max(by: { $0.count < $1.count })
263264
let a11yLabel: String = {
264265
if let peak, peak.count > 0 {
265-
return "12-hour activity chart. \(total) messages in trailing window. Peak hour: \(Self.formatHourLabel(peak.hour)) with \(peak.count) messages"
266+
return "24-hour activity chart. \(total) messages in trailing window. Peak hour: \(Self.formatHourLabel(peak.hour)) with \(peak.count) messages"
266267
}
267-
return "12-hour activity chart. \(total) messages in trailing window"
268+
return "24-hour activity chart. \(total) messages in trailing window"
268269
}()
269270

270271
return Chart {
@@ -302,7 +303,7 @@ struct ActivityChartView: View {
302303
}
303304
}
304305
.chartXAxis {
305-
AxisMarks(values: [0, 3, 6, 9, 11]) { value in
306+
AxisMarks(values: [0, 4, 8, 12, 16, 20, 23]) { value in
306307
AxisValueLabel {
307308
if let offset = value.as(Int.self), offset >= 0, offset < data.count {
308309
Text(Self.formatHourLabel(data[offset].hour))
@@ -311,13 +312,13 @@ struct ActivityChartView: View {
311312
}
312313
}
313314
}
314-
.chartXScale(domain: 0...11)
315+
.chartXScale(domain: 0...23)
315316
.chartYAxis { sharedYAxis }
316317
.chartPlotStyle { plot in plot.background(.clear) }
317318
.chartOverlay { proxy in
318319
chartHoverOverlay(proxy: proxy) { x in
319320
guard let value: Double = proxy.value(atX: x) else { return }
320-
selectedHourlyOffset = max(0, min(11, Int(value.rounded())))
321+
selectedHourlyOffset = max(0, min(23, Int(value.rounded())))
321322
} onEnd: {
322323
selectedHourlyOffset = nil
323324
}
@@ -460,6 +461,65 @@ struct ActivityChartView: View {
460461
}
461462
}
462463

464+
// MARK: - Insight rows (merged from former Insights section)
465+
466+
private static let insightLabelWidth: CGFloat = 55
467+
468+
@ViewBuilder
469+
private func insightRows(_ snapshot: UsageSnapshot) -> some View {
470+
// All Time
471+
insightRow(
472+
label: "All Time",
473+
value: "\(snapshot.totalMessages) msgs \u{00B7} \(snapshot.totalSessions) sessions",
474+
tooltip: "Cumulative activity across all sessions"
475+
)
476+
.accessibilityLabel("All time: \(snapshot.totalMessages) messages, \(snapshot.totalSessions) sessions")
477+
478+
// Longest session
479+
if let duration = snapshot.longestSessionDuration, snapshot.longestSessionMessages > 0 {
480+
insightRow(
481+
label: "Longest",
482+
value: "\(duration) \u{00B7} \(snapshot.longestSessionMessages) msgs",
483+
tooltip: "Longest single session by duration"
484+
)
485+
}
486+
487+
// Date range
488+
if let start = snapshot.firstSessionDate,
489+
let lastDay = snapshot.dailyActivity.last?.date,
490+
let end = DateFormatters.dateKey.date(from: lastDay) {
491+
insightRow(
492+
label: "Period",
493+
value: DateFormatters.formatDateRange(from: start, to: end),
494+
tooltip: "Date range of tracked data",
495+
valueColor: ThemeColors.secondaryLabel
496+
)
497+
}
498+
}
499+
500+
private func insightRow(
501+
label: String,
502+
value: String,
503+
tooltip: String,
504+
valueColor: Color = .primary
505+
) -> some View {
506+
HStack {
507+
Text(label)
508+
.font(.caption)
509+
.foregroundStyle(ThemeColors.secondaryLabel)
510+
.frame(width: Self.insightLabelWidth, alignment: .leading)
511+
.help(tooltip)
512+
Spacer()
513+
Text(value)
514+
.font(.system(.caption, design: .monospaced))
515+
.foregroundStyle(valueColor)
516+
.contentTransition(.numericText())
517+
.animation(.easeInOut(duration: 0.4), value: value)
518+
.copyable(value)
519+
}
520+
.accessibilityElement(children: .combine)
521+
}
522+
463523
// MARK: - Tooltip annotation
464524

465525
/// Shared tooltip label styling used by all chart hover annotations.

0 commit comments

Comments
 (0)