Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ make package # produce dist/CodingBar.app
Click the menu-bar item to open a three-tab panel:

- **Overview** (总览) — outcome next to cost (git output ‖ today's spend), `$/line` & `$/commit`, the live coach (context fuel + savings tips), quota bars with burn-down forecasts, and a 7-day trend.
- **Composition** (构成) — where the money went: spend broken down by model and by project.
- **Composition** (构成) — where the money went: spend broken down by model and by project. Click a project to see its model costs and hourly or daily spend for the selected range.
- **Insights** (洞察) — code output, tool-use mix, golden-hours heatmap, savings tips, and a quota-depletion forecast.

> **How the numbers are computed.** *Spend* is an **estimate at standard pay-as-you-go API prices** (`Pricing.swift`), **not your subscription bill** — on a Max / ChatGPT plan, read it as "equivalent API value," not money actually charged. Models without a published rate use a visibly approximate estimate. *Code output* is **approximate git attribution**: all non-merge commits in a session's working directory within the time window — it can't tell hand-written from AI commits and excludes uncommitted work. *Codex* uses per-response token usage in recent logs; older logs use cumulative-counter deltas without charging the prior session's starting balance.
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodingBar/RenderDebug.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ enum RenderDebug {
store.language = language
store.snapshot = Self.scenarioSnapshot(scenario, language: language)
store.menuMetric = metric
let view = PanelView(store: store, initialTab: tab, scrollable: false)
let view = PanelView(store: store, initialTab: tab, scrollable: false,
initialProjectPath: scenario == "project-detail" ? store.snapshot.projects.first?.path : nil)
.environment(\.colorScheme, dark ? .dark : .light)
write(view, to: path, scale: 2)
}
Expand Down
26 changes: 26 additions & 0 deletions Sources/CodingBar/SelfTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,23 @@ enum SelfTest {
if let data = try? JSONEncoder().encode(sample),
let back = try? JSONDecoder().decode(Snapshot.self, from: data) {
check("sample snapshot round-trips", back.overview.spend.sessions == 7)
check("project detail round-trips", back.projects.first?.models.count == 2
&& back.projects.first?.trend.count == sample.projects.first?.trend.count)
} else {
check("sample snapshot round-trips", false)
check("project detail round-trips", false)
}
if let project = sample.projects.first,
let data = try? JSONEncoder().encode(project),
var legacy = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
legacy.removeValue(forKey: "models")
legacy.removeValue(forKey: "trend")
let oldData = try? JSONSerialization.data(withJSONObject: legacy)
let restored = oldData.flatMap { try? JSONDecoder().decode(ProjectStat.self, from: $0) }
check("older project JSON remains readable", restored?.models.isEmpty == true
&& restored?.trend.isEmpty == true)
} else {
check("older project JSON remains readable", false)
}

check("humanTokens M", UsageStore.humanTokens(1_240_000).hasSuffix("M"))
Expand Down Expand Up @@ -119,6 +134,17 @@ enum SelfTest {
let monthModels = snap.overviews.first { $0.range == .month }?.models.count ?? 0
let todayModels = snap.overviews.first { $0.range == .today }?.models.count ?? 0
check("month composition ⊇ today", monthModels >= todayModels)
let projectDetailsAgree = snap.overviews.flatMap(\.projects).allSatisfy { project in
let modelCost = project.models.reduce(0) { $0 + $1.cost }
let modelTokens = project.models.reduce(0) { $0 + $1.tokens.total }
let trendCost = project.trend.reduce(0) { $0 + $1.cost }
let trendTokens = project.trend.reduce(0) { $0 + $1.tokens }
return abs(modelCost - project.cost) < 0.000_001
&& modelTokens == project.tokens.total
&& abs(trendCost - project.cost) < 0.000_001
&& trendTokens == project.tokens.total
}
check("project detail matches range totals", projectDetailsAgree)

// ── Refresh pass reuses work ────────────────────────────────────────────
// Both assertions guard a silent regression: the numbers stay correct either
Expand Down
186 changes: 170 additions & 16 deletions Sources/CodingBar/Views/Panel/PanelTabs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -425,9 +425,18 @@ struct CostTab: View {
@ObservedObject var store: UsageStore
@State private var modelsExpanded = false
@State private var projectsExpanded = false
@State private var selectedProject: ProjectStat?
@State private var selectedProjectRange: Range = .today
@State private var timelineExpanded = false
private let modelCap = 4
private let projectCap = 5

init(store: UsageStore, initialProject: ProjectStat? = nil) {
self.store = store
self._selectedProject = State(initialValue: initialProject)
self._selectedProjectRange = State(initialValue: store.selectedRange)
}

private var snap: Snapshot { store.snapshot }
private var metric: MenuMetric { store.menuMetric }
private var range: Range { store.selectedRange }
Expand Down Expand Up @@ -458,6 +467,16 @@ struct CostTab: View {
private var shownProjects: [ProjectStat] { projectsExpanded ? sortedProjects : Array(sortedProjects.prefix(projectCap)) }

var body: some View {
Group {
if let selectedProject {
projectDetail(selectedProject)
} else {
costOverview
}
}
}

private var costOverview: some View {
VStack(spacing: 0) {
DCSection {
VStack(alignment: .leading, spacing: 0) {
Expand Down Expand Up @@ -499,6 +518,130 @@ struct CostTab: View {
}
}

private func projectDetail(_ selected: ProjectStat) -> some View {
let project = (snap.overviews.first { $0.range == selectedProjectRange }?.projects
?? snap.projects).first { $0.path == selected.path } ?? selected
Comment on lines +522 to +523

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear project details when the refreshed range drops the project

When the periodic refresh produces an overview where the selected project is no longer present—such as after midnight for the Today range, or when it falls outside the top-eight project cutoff—the lookup fails and ?? selected keeps rendering the old ProjectStat. The detail page therefore continues to label stale totals as the current range indefinitely; clear/close the selection or render a missing-project state instead of falling back to the pre-refresh value.

Useful? React with 👍 / 👎.

let models = project.models
let maxCost = max(models.map(\.cost).max() ?? 0, 1e-9)
let maxTokens = max(Double(models.map { $0.tokens.total }.max() ?? 0), 1)
return VStack(spacing: 0) {
DCSection {
VStack(alignment: .leading, spacing: 0) {
HStack {
Button { selectedProject = nil } label: {
HStack(spacing: 4) {
Image(systemName: "chevron.left").font(.system(size: 9, weight: .semibold))
Text(lang.t("By project", "按项目")).font(.system(size: 10.5, weight: .medium))
}
.foregroundStyle(dc.accent)
.contentShape(Rectangle())
}
.buttonStyle(.plain).focusEffectDisabled()
Spacer()
Text(projectRangeLabel).font(.system(size: 10)).foregroundStyle(dc.fg3)
}
.padding(.bottom, 15)

Text(project.name).font(.system(size: 17, weight: .semibold)).foregroundStyle(dc.fg)
.lineLimit(2)
Text(project.path).font(.system(size: 9.5)).foregroundStyle(dc.fg3)
.lineLimit(2).textSelection(.enabled).padding(.top, 3)

Text(metric == .cost ? Panel.usd(project.cost) : Panel.tok(project.tokens.total))
.font(.system(size: 32, weight: .bold)).monospacedDigit().tracking(-0.7)
.foregroundStyle(dc.fg).padding(.top, 14)
Text(metric == .cost
? lang.t("\(Panel.tok(project.tokens.total)) tokens · standard API estimate",
"\(Panel.tok(project.tokens.total)) Token · 标准 API 价格估算")
: lang.t("\(Panel.usd(project.cost)) · standard API estimate",
"\(Panel.usd(project.cost)) · 标准 API 价格估算"))
.font(.system(size: 10)).foregroundStyle(dc.fg3).padding(.top, 2)

if project.trend.count >= 3 {
DCSparkline(values: metric == .cost
? project.trend.map(\.cost)
: project.trend.map { Double($0.tokens) })
.padding(.top, 15)
HStack {
Text(projectTrendStart(project.trend[0].date))
Spacer()
Text(lang.t("now", "现在"))
}
.font(.system(size: 9)).foregroundStyle(dc.fg3)
.padding(.horizontal, 3).padding(.top, 6)
}
}
}
if !models.isEmpty {
DCSection {
VStack(alignment: .leading, spacing: 11) {
DCLabel(metric == .cost ? lang.t("By model · cost", "按模型 · 花费")
: lang.t("By model · tokens", "按模型 · Token"))
ForEach(metric == .cost ? models : models.sorted { $0.tokens.total > $1.tokens.total }) {
modelRow($0, maxCost: maxCost, maxTokens: maxTokens)
}
}
}
}
if project.trend.contains(where: { $0.tokens > 0 }) {
projectTimeline(project.trend)
}
}
}

private func projectTimeline(_ trend: [DayPoint]) -> some View {
let points = trend.filter { $0.tokens > 0 }.reversed()
let maxValue = max(points.map { metric == .cost ? $0.cost : Double($0.tokens) }.max() ?? 0, 1e-9)
let shown = timelineExpanded ? Array(points) : Array(points.prefix(7))
return DCSection {
VStack(alignment: .leading, spacing: 10) {
DCLabel(selectedProjectRange == .today
? lang.t("By hour", "按小时") : lang.t("By day", "按天"))
ForEach(shown) { point in
HStack(spacing: 9) {
Text(projectTimeLabel(point.date))
.font(.system(size: 10.5)).foregroundStyle(dc.fg2)
.frame(width: 42, alignment: .leading)
GeometryReader { g in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2).fill(dc.track)
RoundedRectangle(cornerRadius: 2).fill(dc.accent.opacity(0.65))
.frame(width: g.size.width * max(0.03, (metric == .cost ? point.cost : Double(point.tokens)) / maxValue))
}
}
.frame(height: 4)
Text(metric == .cost ? Panel.usd(point.cost) : Panel.tok(point.tokens))
.font(.system(size: 10.5, weight: .semibold)).monospacedDigit()
.foregroundStyle(dc.fg).frame(width: 69, alignment: .trailing)
}
}
if points.count > 7 {
expandToggle(timelineExpanded, points.count - 7) { timelineExpanded.toggle() }
}
}
}
}

private var projectRangeLabel: String {
switch selectedProjectRange {
case .today: return lang.t("Today", "今日")
case .week: return lang.t("Last 7d", "近 7 天")
case .month: return lang.t("Last 30d", "近 30 天")
}
}

private func projectTrendStart(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = selectedProjectRange == .today ? "H:mm" : "M/d"
return formatter.string(from: date)
}

private func projectTimeLabel(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = selectedProjectRange == .today ? "H:00" : "M/d"
return formatter.string(from: date)
}

// MARK: 用量归因 — Skills / Subagents / Plugins / MCP servers (`/usage` "% of usage").
// Claude-only (Codex carries no attribution tags); follows the range pill + metric.

Expand Down Expand Up @@ -654,10 +797,10 @@ struct CostTab: View {
.buttonStyle(.plain).focusEffectDisabled()
}

private func modelRow(_ m: ModelStat) -> some View {
private func modelRow(_ m: ModelStat, maxCost: Double? = nil, maxTokens: Double? = nil) -> some View {
let name = (m.provider == .claude ? "Claude " : "Codex ") + Pricing.displayName(forCanonicalKey: m.model)
let metricVal = metric == .cost ? m.cost : Double(m.tokens.total)
let maxVal = metric == .cost ? maxModelCost : maxModelTokens
let maxVal = metric == .cost ? (maxCost ?? maxModelCost) : (maxTokens ?? maxModelTokens)
let share = max(3.0, metricVal / maxVal * 100)
return VStack(spacing: 4) {
HStack(spacing: 7) {
Expand Down Expand Up @@ -694,24 +837,35 @@ struct CostTab: View {
let metricVal = metric == .cost ? p.cost : Double(p.tokens.total)
let maxVal = metric == .cost ? maxProjCost : maxProjTokens
let share = max(3.0, metricVal / maxVal * 100)
return HStack(spacing: 9) {
VStack(alignment: .leading, spacing: 4) {
Text(p.name).font(.system(size: 11, weight: .medium)).foregroundStyle(dc.fg).lineLimit(1)
GeometryReader { g in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2).fill(dc.track)
RoundedRectangle(cornerRadius: 2).fill(dc.accent.opacity(0.55))
.frame(width: g.size.width * share / 100)
return Button {
selectedProject = p
selectedProjectRange = range
timelineExpanded = false
} label: {
HStack(spacing: 9) {
VStack(alignment: .leading, spacing: 4) {
Text(p.name).font(.system(size: 11, weight: .medium)).foregroundStyle(dc.fg).lineLimit(1)
GeometryReader { g in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2).fill(dc.track)
RoundedRectangle(cornerRadius: 2).fill(dc.accent.opacity(0.55))
.frame(width: g.size.width * share / 100)
}
}
.frame(height: 3)
}
.frame(height: 3)
}
VStack(alignment: .trailing, spacing: 1) {
Text(metric == .cost ? Panel.usd(p.cost) : Panel.tok(p.tokens.total))
.font(.system(size: 11, weight: .semibold)).monospacedDigit().foregroundStyle(dc.fg)
Text(lastLabel(p.lastActive)).font(.system(size: 9)).foregroundStyle(dc.fg3)
VStack(alignment: .trailing, spacing: 1) {
Text(metric == .cost ? Panel.usd(p.cost) : Panel.tok(p.tokens.total))
.font(.system(size: 11, weight: .semibold)).monospacedDigit().foregroundStyle(dc.fg)
Text(lastLabel(p.lastActive)).font(.system(size: 9)).foregroundStyle(dc.fg3)
}
Image(systemName: "chevron.right")
.font(.system(size: 8, weight: .semibold)).foregroundStyle(dc.fg3)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain).focusEffectDisabled()
.help(p.path)
}

private func lastLabel(_ d: Date) -> String {
Expand Down
7 changes: 5 additions & 2 deletions Sources/CodingBar/Views/Panel/PanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@ struct PanelView: View {
/// Live popover scrolls within a screen-bounded height; offscreen rendering
/// (ImageRenderer has no update pass) uses natural height instead.
let scrollable: Bool
let initialProjectPath: String?

init(store: UsageStore, initialTab: Int = 0, scrollable: Bool = true, initialSettings: Bool = false) {
init(store: UsageStore, initialTab: Int = 0, scrollable: Bool = true,
initialSettings: Bool = false, initialProjectPath: String? = nil) {
self.store = store
self._tab = State(initialValue: initialTab)
self.scrollable = scrollable
self.initialProjectPath = initialProjectPath
self._showSettings = State(initialValue: initialSettings)
}

Expand Down Expand Up @@ -78,7 +81,7 @@ struct PanelView: View {

@ViewBuilder private var tabContent: some View {
switch tab {
case 1: CostTab(store: store)
case 1: CostTab(store: store, initialProject: snap.projects.first { $0.path == initialProjectPath })
case 2: InsightsTab(store: store)
default: OverviewTab(store: store, onShowInsights: { tab = 2 })
}
Expand Down
Loading
Loading